Vendored
+3
@@ -0,0 +1,3 @@
|
||||
> 1%
|
||||
last 2 versions
|
||||
not dead
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
max_line_length = 100
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
EAGLECAST_FRONTEND_PORT=8080
|
||||
EAGLECAST_API_URL="http://127.0.0.1:9000"
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
// es2022: true,
|
||||
},
|
||||
plugins: ['vue'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/essential',
|
||||
'plugin:vue/strongly-recommended',
|
||||
'@vue/eslint-config-airbnb',
|
||||
],
|
||||
parser: 'vue-eslint-parser',
|
||||
rules: {
|
||||
'class-methods-use-this': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/quote-props': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
'vue/no-child-content': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/html-closing-bracket-newline': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/max-len': ['error', {
|
||||
code: 200,
|
||||
template: 200,
|
||||
comments: 200,
|
||||
}],
|
||||
},
|
||||
ignorePatterns: ['src/email-builder.js'],
|
||||
};
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Log files
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
# EagleCast frontend (Vue + Buefy)
|
||||
|
||||
It's best if the `eaglecast/frontend` directory is opened in an IDE as a separate project where the frontend directory is the root of the project.
|
||||
|
||||
For developer setup instructions, refer to the main project's README.
|
||||
|
||||
## Globals
|
||||
In `main.js`, Buefy and vue-i18n are attached globally. In addition:
|
||||
|
||||
- `$api` (collection of API calls from `api/index.js`)
|
||||
- `$utils` (util functions from `util.js`). They are accessible within Vue as `this.$api` and `this.$utils`.
|
||||
|
||||
Some constants are defined in `constants.js`.
|
||||
|
||||
|
||||
## APIs and states
|
||||
The project uses a global `vuex` state to centrally store the responses to pretty much all APIs (eg: fetch lists, campaigns etc.) except for a few exceptions. These are called `models` and have been defined in `constants.js`. The definitions are in `store/index.js`.
|
||||
|
||||
There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses.
|
||||
|
||||
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistency in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`.
|
||||
|
||||
|
||||
## Icon pack
|
||||
Buefy by default uses [Material Design Icons](https://materialdesignicons.com) (MDI) with icon classes prefixed by `mdi-`.
|
||||
|
||||
EagleCast uses only a handful of icons from the massive MDI set packed as web font, using [Fontello](https://fontello.com). To add more icons to the set using fontello:
|
||||
|
||||
- Go to Fontello and drag and drop `frontend/fontello/config.json` (This is the full MDI set converted from TTF to SVG icons to work with Fontello).
|
||||
- Use the UI to search for icons and add them to the selection (add icons from under the `Custom` section)
|
||||
- Download the Fontello pack and from the ZIP:
|
||||
- Copy and overwrite `config.json` to `frontend/fontello`
|
||||
- Copy `fontello.woff2` to `frontend/src/assets/icons`.
|
||||
- Open `css/fontello.css` and copy the individual icon definitions and overwrite the ones in `frontend/src/assets/icons/fontello.css`
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset',
|
||||
],
|
||||
};
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
const { defineConfig } = require('cypress');
|
||||
|
||||
module.exports = defineConfig({
|
||||
env: {
|
||||
apiUrl: 'http://localhost:9000',
|
||||
serverInitCmd:
|
||||
'pkill -9 eaglecast; cd ../ && EAGLECAST_ADMIN_USER=admin EAGLECAST_ADMIN_PASSWORD=eaglecast ./eaglecast --install --yes && setsid ./eaglecast </dev/null >/dev/null 2>&1 &',
|
||||
serverInitBlankCmd:
|
||||
'pkill -9 eaglecast; cd ../ && ./eaglecast --install --yes && setsid ./eaglecast </dev/null >/dev/null 2>&1 &',
|
||||
EAGLECAST_ADMIN_USER: 'admin',
|
||||
EAGLECAST_ADMIN_PASSWORD: 'eaglecast',
|
||||
},
|
||||
viewportWidth: 1400,
|
||||
viewportHeight: 950,
|
||||
e2e: {
|
||||
experimentalRunAllSpecs: true,
|
||||
testIsolation: false,
|
||||
experimentalSessionAndOrigin: false,
|
||||
// We've imported your old cypress plugins here.
|
||||
// You may want to clean this up later by importing these.
|
||||
setupNodeEvents(on, config) {
|
||||
return require('./cypress/plugins/index.js')(on, config);
|
||||
},
|
||||
baseUrl: 'http://localhost:9000',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
describe('Archive', () => {
|
||||
it('Opens campaigns page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
cy.wait(500);
|
||||
});
|
||||
|
||||
it('Clones campaign', () => {
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
cy.get('[data-cy=btn-clone]').first().click();
|
||||
cy.get('.modal input').clear().type('clone').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(250);
|
||||
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
cy.get('[data-cy=btn-clone]').first().click();
|
||||
cy.get('.modal input').clear().type('clone2').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(250);
|
||||
|
||||
cy.clickMenu('all-campaigns');
|
||||
});
|
||||
|
||||
it('Starts campaigns', () => {
|
||||
cy.get('td[data-label=Status] a').eq(0).click();
|
||||
cy.get('[data-cy=btn-start]').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
|
||||
cy.get('td[data-label=Status] a').eq(1).click();
|
||||
cy.get('[data-cy=btn-start]').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(1000);
|
||||
});
|
||||
|
||||
it('Enables archive on one campaign (no slug)', () => {
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
cy.wait(250);
|
||||
cy.get('td[data-label=Status] a').eq(0).click();
|
||||
|
||||
// Switch to archive tab and enable archive.
|
||||
cy.get('.b-tabs nav a').eq(3).click();
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy=btn-archive] .check').click();
|
||||
cy.get('[data-cy=archive-slug]').clear();
|
||||
cy.get('[data-cy=archive-meta]').clear()
|
||||
.type('{"email": "archive@domain.com", "name": "Archive", "attribs": { "city": "Bengaluru"}}', { parseSpecialCharSequences: false });
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(250);
|
||||
});
|
||||
|
||||
it('Enables archive on one campaign', () => {
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
cy.wait(250);
|
||||
cy.get('td[data-label=Status] a').eq(1).click();
|
||||
|
||||
// Switch to archive tab and enable archive.
|
||||
cy.get('.b-tabs nav a').eq(3).click();
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy=btn-archive] .check').click();
|
||||
cy.get('[data-cy=archive-slug]').clear().type('my-archived-campaign');
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(250);
|
||||
});
|
||||
|
||||
it('Opens campaign archive page', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
cy.loginAndVisit(`${apiUrl}/archive`);
|
||||
cy.get('li a').eq(i).click();
|
||||
cy.wait(250);
|
||||
if (i === 0) {
|
||||
cy.get('h3').contains('Hi Archive!');
|
||||
cy.get('p').eq(0).contains('Bengaluru');
|
||||
} else {
|
||||
cy.get('h3').contains('Hi Subscriber!');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
describe('Bounces', () => {
|
||||
const subs = [];
|
||||
|
||||
it('Enable bounces', () => {
|
||||
cy.resetDB();
|
||||
|
||||
cy.loginAndVisit('/admin/settings');
|
||||
cy.get('.b-tabs nav a').eq(6).click();
|
||||
cy.get('[data-cy=btn-enable-bounce] .switch').click();
|
||||
cy.get('[data-cy=btn-enable-bounce-webhook] .switch').click();
|
||||
|
||||
// The last <select> box, pick 'delete' option.
|
||||
cy.get('select[name="bounce.action"]').last().select('delete');
|
||||
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
|
||||
cy.waitForBackend();
|
||||
});
|
||||
|
||||
it('Post bounces', () => {
|
||||
cy.loginAndVisit('/admin/subscribers/bounces');
|
||||
|
||||
// Get campaign.
|
||||
let camp = {};
|
||||
cy.request(`${apiUrl}/api/campaigns`).then((resp) => {
|
||||
camp = resp.body.data.results[0];
|
||||
}).then(() => {
|
||||
console.log('campaign is ', camp.uuid);
|
||||
});
|
||||
|
||||
// Get subscribers.
|
||||
let subs = [];
|
||||
cy.request(`${apiUrl}/api/subscribers`).then((resp) => {
|
||||
subs = resp.body.data.results;
|
||||
}).then(() => {
|
||||
// Register soft bounces do nothing.
|
||||
let sub = {};
|
||||
cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email });
|
||||
cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'soft', email: subs[0].email });
|
||||
cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => {
|
||||
sub = resp.body.data;
|
||||
}).then(() => {
|
||||
cy.expect(sub.status).to.equal('enabled');
|
||||
});
|
||||
|
||||
// Hard bounces blocklist.
|
||||
cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email });
|
||||
cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'hard', email: subs[0].email });
|
||||
|
||||
cy.request(`${apiUrl}/api/subscribers/${subs[0].id}`).then((resp) => {
|
||||
sub = resp.body.data;
|
||||
}).then(() => {
|
||||
cy.expect(sub.status).to.equal('blocklisted');
|
||||
});
|
||||
|
||||
// Complaint bounces delete.
|
||||
cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email });
|
||||
cy.request('POST', `${apiUrl}/webhooks/bounce`, { source: 'api', type: 'complaint', email: subs[1].email });
|
||||
cy.request({ url: `${apiUrl}/api/subscribers/${subs[1].id}`, failOnStatusCode: false }).then((resp) => {
|
||||
expect(resp.status).to.eq(400);
|
||||
});
|
||||
|
||||
cy.loginAndVisit('/admin/subscribers/bounces');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,525 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
const headers = '[{"X-Custom": "Custom-Value"}]';
|
||||
|
||||
describe('Campaigns', () => {
|
||||
it('Opens campaigns page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
});
|
||||
|
||||
it('Counts campaigns', () => {
|
||||
cy.get('tbody td[data-label=Status]').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('Creates campaign', () => {
|
||||
cy.get('a[data-cy=btn-new]').click();
|
||||
|
||||
// Fill fields.
|
||||
cy.get('input[name=name]').clear().type('new-attach');
|
||||
cy.get('input[name=subject]').clear().type('new-subject');
|
||||
cy.get('input[name=from_email]').clear().type('new <from@email>');
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').eq(0).click();
|
||||
|
||||
cy.get('button[data-cy=btn-continue]').click();
|
||||
cy.wait(500);
|
||||
|
||||
cy.get('a[data-cy=btn-attach]').click();
|
||||
cy.get('button[data-cy=btn-toggle-upload]').click();
|
||||
cy.wait(500);
|
||||
cy.get('input[type=file]').attachFile('example.json');
|
||||
cy.get('form[data-cy="upload"] button').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.wait(500);
|
||||
cy.get('.modal a.thumb-link').click();
|
||||
cy.get('button[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
|
||||
// Re-open and check that the file still exists.
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
cy.get('td[data-label=Status] a').eq(0).click();
|
||||
cy.get('.b-tabs nav a').eq(1).click();
|
||||
cy.get('div.field[data-cy=media]').contains('example');
|
||||
|
||||
// Start.
|
||||
cy.get('button[data-cy=btn-start]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.wait(500);
|
||||
cy.get('tbody tr').eq(0).within(() => {
|
||||
cy.get('td[data-label=Status] .tag').should(($tag) => {
|
||||
expect($tag.hasClass('running') || $tag.hasClass('finished')).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Edits campaign', () => {
|
||||
cy.get('td[data-label=Status] a').eq(1).click();
|
||||
|
||||
// Fill fields.
|
||||
cy.get('input[name=name]').clear().type('new-name');
|
||||
cy.get('input[name=subject]').clear().type('new-subject');
|
||||
cy.get('input[name=from_email]').clear().type('new <from@email>');
|
||||
|
||||
// Change the list.
|
||||
cy.get('.list-selector a.delete').click();
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').eq(0).click();
|
||||
|
||||
// Clear and redo tags.
|
||||
cy.get('input[name=tags]').type('{backspace}new-tag{enter}');
|
||||
|
||||
// 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);
|
||||
|
||||
// Add custom headers.
|
||||
cy.get('[data-cy=btn-headers]').click();
|
||||
cy.get('textarea[name=headers]').invoke('val', headers).trigger('input');
|
||||
|
||||
// Switch to content tab.
|
||||
cy.get('.b-tabs nav a').eq(1).click();
|
||||
|
||||
// Switch format to plain text.
|
||||
cy.get('select[name=content_type]').select('plain');
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
|
||||
// Enter body value.
|
||||
cy.get('textarea[name=content]').clear().type('new-content');
|
||||
cy.get('button[data-cy=btn-save]').click();
|
||||
|
||||
// Schedule.
|
||||
cy.get('button[data-cy=btn-schedule]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
|
||||
cy.wait(250);
|
||||
|
||||
// Verify the changes.
|
||||
cy.request(`${apiUrl}/api/campaigns/1`).should((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data.status).to.equal('scheduled');
|
||||
expect(data.name).to.equal('new-name');
|
||||
expect(data.subject).to.equal('new-subject');
|
||||
expect(data.content_type).to.equal('plain');
|
||||
expect(data.altbody).to.equal(null);
|
||||
expect(data.send_at).to.not.equal(null);
|
||||
expect(data.body).to.equal('new-content');
|
||||
|
||||
expect(data.lists.length).to.equal(1);
|
||||
expect(data.lists[0].id).to.equal(1);
|
||||
expect(data.tags.length).to.equal(1);
|
||||
expect(data.tags[0]).to.equal('new-tag');
|
||||
expect(data.headers[0]['X-Custom']).to.equal('Custom-Value');
|
||||
});
|
||||
|
||||
cy.get('tbody td[data-label=Status] .tag.scheduled');
|
||||
});
|
||||
|
||||
it('Unschedules campaign', () => {
|
||||
cy.get('td[data-label=Status] a').eq(1).click();
|
||||
cy.wait(250);
|
||||
cy.get('button[data-cy=btn-unschedule]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.wait(250);
|
||||
cy.visit('/admin/campaigns');
|
||||
|
||||
// Check if the status label has the inner text `Draft`.
|
||||
cy.get('td[data-label=Status] .tag.draft').should('have.length', 1);
|
||||
});
|
||||
|
||||
it('Switches formats', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/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.visit('/admin/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('select[name=content_type]').select(c);
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
|
||||
// Check content via the preview API (the iframe is sandboxed and inaccessible to JS).
|
||||
if (c !== 'plain') {
|
||||
cy.location('pathname').then((p) => {
|
||||
cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1).replace(/#.*/, '')}/preview`).then((resp) => {
|
||||
// Strip HTML tags and normalize whitespace.
|
||||
const text = resp.body.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
||||
expect(text).to.contain(plainBody);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cy.visit('/admin/campaigns');
|
||||
});
|
||||
});
|
||||
|
||||
it('Clones campaign', () => {
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
for (let n = 0; n < 3; n++) {
|
||||
// Clone the campaign.
|
||||
cy.get('[data-cy=btn-clone]').first().click();
|
||||
cy.get('.modal input').clear().type(`clone${n}`).click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.wait(250);
|
||||
cy.clickMenu('all-campaigns');
|
||||
cy.wait(100);
|
||||
|
||||
// Verify the newly created row.
|
||||
cy.get('tbody td[data-label="Name"]').first().contains(`clone${n}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Searches campaigns', () => {
|
||||
cy.get('input[name=query]').clear().type('clone2{enter}');
|
||||
cy.get('tbody tr').its('length').should('eq', 1);
|
||||
cy.get('tbody td[data-label="Name"]').first().contains('clone2');
|
||||
cy.get('input[name=query]').clear().type('{enter}');
|
||||
});
|
||||
|
||||
it('Deletes campaigns', () => {
|
||||
cy.wait(1000);
|
||||
// Delete all visible lists.
|
||||
cy.get('tbody tr').each(() => {
|
||||
cy.get('tbody a[data-cy=btn-delete]').first().click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
});
|
||||
|
||||
// Confirm deletion.
|
||||
cy.get('table tr.is-empty');
|
||||
});
|
||||
|
||||
it('Adds new campaigns', () => {
|
||||
const lists = [[1], [1, 2]];
|
||||
const cTypes = ['richtext', 'html', 'markdown', 'plain', 'visual'];
|
||||
|
||||
let n = 0;
|
||||
cTypes.forEach((c) => {
|
||||
lists.forEach((l) => {
|
||||
// Click the 'new button'
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.wait(100);
|
||||
|
||||
// Fill fields.
|
||||
cy.get('input[name=name]').clear().type(`name${n}`);
|
||||
cy.get('input[name=subject]').clear().type(`subject${n}`);
|
||||
|
||||
l.forEach(() => {
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
});
|
||||
|
||||
// Add tags.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cy.get('input[name=tags]').type(`tag${i}{enter}`);
|
||||
}
|
||||
|
||||
// Add headers.
|
||||
cy.get('[data-cy=btn-headers]').click();
|
||||
cy.get('textarea[name=headers]').invoke('val', `[{"X-Header-${n}": "Value-${n}"}]`).trigger('input');
|
||||
|
||||
// Hit 'Continue'.
|
||||
cy.get('button[data-cy=btn-continue]').click();
|
||||
cy.wait(250);
|
||||
|
||||
// Verify the changes.
|
||||
(function (n) {
|
||||
cy.location('pathname').then((p) => {
|
||||
cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1).replace(/#.*/, '')}`).should((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data.status).to.equal('draft');
|
||||
expect(data.name).to.equal(`name${n}`);
|
||||
expect(data.subject).to.equal(`subject${n}`);
|
||||
expect(data.content_type).to.equal('richtext');
|
||||
expect(data.altbody).to.equal(null);
|
||||
expect(data.send_at).to.equal(null);
|
||||
expect(data.headers[0][`X-Header-${n}`]).to.equal(`Value-${n}`);
|
||||
});
|
||||
});
|
||||
}(n));
|
||||
|
||||
// Select content type.
|
||||
cy.get('select[name=content_type]').select(c);
|
||||
|
||||
// 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`;
|
||||
|
||||
cy.log(`format = ${c}`);
|
||||
if (c === 'richtext') {
|
||||
cy.window().then((win) => {
|
||||
win.tinymce.editors[0].setContent(htmlBody);
|
||||
win.tinymce.editors[0].save();
|
||||
});
|
||||
cy.wait(500);
|
||||
} else if (c === 'html') {
|
||||
// Use CodeMirror 6 via the Vue component's editor instance.
|
||||
cy.get('.code-editor').then(($el) => {
|
||||
const view = $el[0].__vue__.editor;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: htmlBody } });
|
||||
});
|
||||
} else if (c === 'markdown') {
|
||||
cy.get('.code-editor').then(($el) => {
|
||||
const view = $el[0].__vue__.editor;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: markdownBody } });
|
||||
});
|
||||
} else if (c === 'plain') {
|
||||
cy.get('textarea[name=content]').invoke('val', plainBody).trigger('input');
|
||||
} else if (c === 'visual') {
|
||||
cy.wait(200);
|
||||
cy.get('iframe').then((el) => {
|
||||
cy.wait(200);
|
||||
cy.wrap(el.contents()).find('table td').click();
|
||||
cy.wait(200);
|
||||
cy.wrap(el.contents()).find('textarea').eq(0).type(plainBody);
|
||||
});
|
||||
}
|
||||
|
||||
// Save.
|
||||
cy.get('button[data-cy=btn-save]').click();
|
||||
|
||||
// Verify the preview content via the API (the iframe is sandboxed).
|
||||
if (c !== 'plain') {
|
||||
cy.location('pathname').then((p) => {
|
||||
cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1).replace(/#.*/, '')}/preview`).then((resp) => {
|
||||
const text = resp.body.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim();
|
||||
expect(text).to.contain(plainBody);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
cy.clickMenu('all-campaigns');
|
||||
cy.wait(500);
|
||||
|
||||
// Verify the newly created campaign in the table.
|
||||
cy.get('tbody td[data-label="Name"]').first().contains(`name${n}`);
|
||||
cy.get('tbody td[data-label="Name"]').first().contains(`subject${n}`);
|
||||
cy.get('tbody td[data-label="Lists"]').first().then(($el) => {
|
||||
cy.wrap($el).find('li').should('have.length', l.length);
|
||||
});
|
||||
|
||||
n++;
|
||||
});
|
||||
});
|
||||
|
||||
// Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI.
|
||||
cy.request(`${apiUrl}/api/campaigns?order=asc&order_by=created_at`).should((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data.total).to.equal(lists.length * cTypes.length);
|
||||
|
||||
let n = 0;
|
||||
cTypes.forEach((c) => {
|
||||
lists.forEach((l) => {
|
||||
expect(data.results[n].content_type).to.equal(c);
|
||||
expect(data.results[n].lists.map((ls) => ls.id)).to.deep.equal(l);
|
||||
n++;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Starts and cancels campaigns', () => {
|
||||
for (let n = 1; n <= 2; n++) {
|
||||
cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-start]`).click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.wait(250);
|
||||
cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.running`);
|
||||
|
||||
if (n > 1) {
|
||||
cy.get(`tbody tr:nth-child(${n}) [data-cy=btn-cancel]`).click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.wait(250);
|
||||
cy.get(`tbody tr:nth-child(${n}) td[data-label=Status] .tag.cancelled`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('Sorts campaigns', () => {
|
||||
const asc = [5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
|
||||
const desc = [14, 13, 12, 11, 10, 9, 8, 7, 6, 5];
|
||||
const cases = ['cy-name', 'cy-timestamp'];
|
||||
|
||||
cases.forEach((c) => {
|
||||
cy.sortTable(`thead th.${c}`, asc);
|
||||
cy.wait(250);
|
||||
cy.sortTable(`thead th.${c}`, desc);
|
||||
cy.wait(250);
|
||||
});
|
||||
});
|
||||
|
||||
it('Bulk deletes campaigns', () => {
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
const params = {
|
||||
name: 'test-campaign-bulk',
|
||||
subject: 'subject',
|
||||
type: 'regular',
|
||||
content_type: 'richtext',
|
||||
template_id: 1,
|
||||
lists: [1],
|
||||
};
|
||||
|
||||
// Create 30 in a loop.
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
cy.request('POST', `${apiUrl}/api/campaigns`, params);
|
||||
}
|
||||
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
|
||||
// Bulk delete with the `all` flag.
|
||||
cy.window().scrollTo('top');
|
||||
cy.wait(500);
|
||||
cy.get('thead input[type="checkbox"]').click({ force: true });
|
||||
cy.get('a[data-cy=select-all-campaigns]').click();
|
||||
cy.get('a[data-cy=btn-delete-campaigns]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.get('table tr.is-empty');
|
||||
|
||||
// Bulk delete with the selected IDs.
|
||||
// Create 5 campaigns in a loop.
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
cy.request('POST', `${apiUrl}/api/campaigns`, params);
|
||||
}
|
||||
|
||||
cy.visit('/admin/campaigns');
|
||||
cy.wait(500);
|
||||
cy.get('thead input[type="checkbox"]').click({ force: true });
|
||||
cy.get('a[data-cy=btn-delete-campaigns]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.get('table tr.is-empty');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Campaign image embed', () => {
|
||||
const mailhog = 'http://localhost:8025';
|
||||
const logoUrl = `${apiUrl}/uploads/logo.png`;
|
||||
|
||||
// Create a campaign of the given content type via the API and open its content tab.
|
||||
const newCampaign = (name, contentType) => cy.request('POST', `${apiUrl}/api/campaigns`, {
|
||||
name, subject: name, type: 'regular', content_type: contentType, lists: [1],
|
||||
}).then((r) => cy.visit(`/admin/campaigns/${r.body.data.id}#content`).then(() => r.body.data.id));
|
||||
|
||||
// Start the campaign and assert (via the MailHog API) whether the delivered e-mail
|
||||
// embedded the image as a MIME/CID part (embed=true) or left it as a plain URL.
|
||||
const send = (id, name, embed) => {
|
||||
cy.request('PUT', `${apiUrl}/api/campaigns/${id}/status`, { status: 'running' });
|
||||
cy.waitUntil(() => cy.request(`${mailhog}/api/v2/search?kind=containing&query=${name}`)
|
||||
.then((r) => r.body.total > 0), { timeout: 20000, interval: 1000 });
|
||||
cy.request(`${mailhog}/api/v2/search?kind=containing&query=${name}`).then((r) => {
|
||||
const raw = JSON.stringify(r.body.items[0]);
|
||||
expect(/image\/png/.test(raw), 'has inline image part').to.equal(embed);
|
||||
expect(/cid:/.test(raw), 'references cid').to.equal(embed);
|
||||
});
|
||||
};
|
||||
|
||||
// Open the image dialog's media picker and upload/select image.
|
||||
const pickLogo = (upload) => {
|
||||
cy.window().then((win) => win.tinymce.editors[0].execCommand('mceImage'));
|
||||
cy.get('.tox-dialog .tox-browse-url').click();
|
||||
if (upload) {
|
||||
cy.get('[data-cy=btn-toggle-upload]').click();
|
||||
cy.get('input[type=file]').attachFile('logo.png');
|
||||
cy.get('form[data-cy="upload"] button').click();
|
||||
}
|
||||
cy.get('.modal a.thumb-link', { timeout: 10000 }).first().click();
|
||||
cy.get('.tox-dialog input[type=url]').should('have.value', logoUrl);
|
||||
};
|
||||
|
||||
const richtext = (name, embed, upload) => {
|
||||
let id;
|
||||
newCampaign(name, 'richtext').then((cid) => { id = cid; });
|
||||
cy.window().its('tinymce.editors.0').should('exist');
|
||||
pickLogo(upload);
|
||||
if (embed) {
|
||||
cy.get('.tox-dialog input[type=checkbox]').check({ force: true });
|
||||
}
|
||||
cy.get('.tox-dialog__footer button').last().click();
|
||||
cy.get('button[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
|
||||
// The 'embed?' selection is stored on the <img data-embed> and must persist across reloads.
|
||||
cy.then(() => cy.visit(`/admin/campaigns/${id}#content`));
|
||||
cy.window().its('tinymce.editors.0').should('exist');
|
||||
cy.window().then((win) => {
|
||||
const ed = win.tinymce.editors[0];
|
||||
ed.selection.select(ed.getBody().querySelector('img'));
|
||||
ed.execCommand('mceImage');
|
||||
});
|
||||
cy.get('.tox-dialog input[type=checkbox]').should(embed ? 'be.checked' : 'not.be.checked');
|
||||
cy.get('.tox-dialog__footer button').first().click();
|
||||
|
||||
cy.then(() => send(id, name, embed));
|
||||
};
|
||||
|
||||
const visual = (name, embed) => {
|
||||
let id;
|
||||
newCampaign(name, 'visual').then((cid) => { id = cid; });
|
||||
|
||||
// Add an image block pointing at the uploaded image.
|
||||
cy.waitUntil(() => cy.get('#visual-editor')
|
||||
.then(($f) => Boolean($f[0].contentWindow.EmailBuilder
|
||||
&& $f[0].contentWindow.EmailBuilder.isRendered('visual-editor-container'))));
|
||||
cy.get('#visual-editor').then(($f) => $f[0].contentWindow.EmailBuilder.resetDocument({
|
||||
root: { type: 'EmailLayout', data: { childrenIds: ['blk'] } },
|
||||
blk: { type: 'Image', data: { props: { url: logoUrl, embed: false }, style: {} } },
|
||||
}));
|
||||
cy.wait(500);
|
||||
|
||||
// Select image and check 'embed?' checkbox.
|
||||
const clickImage = () => cy.get('#visual-editor')
|
||||
.then(($f) => $f[0].contentDocument.querySelector('#visual-editor-container img').click());
|
||||
clickImage();
|
||||
cy.wait(300);
|
||||
if (embed) {
|
||||
cy.get('#visual-editor').then(($f) => $f[0].contentDocument.querySelector('input[type=checkbox]').click());
|
||||
cy.wait(300);
|
||||
}
|
||||
cy.get('button[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
|
||||
// Reload and confirm checkbox peristence.
|
||||
cy.then(() => cy.visit(`/admin/campaigns/${id}#content`));
|
||||
cy.waitUntil(() => cy.get('#visual-editor')
|
||||
.then(($f) => Boolean($f[0].contentDocument.querySelector('#visual-editor-container img'))));
|
||||
clickImage();
|
||||
cy.wait(300);
|
||||
cy.get('#visual-editor').then(($f) => {
|
||||
expect($f[0].contentDocument.querySelector('input[type=checkbox]').checked).to.equal(embed);
|
||||
});
|
||||
|
||||
cy.then(() => send(id, name, embed));
|
||||
};
|
||||
|
||||
it('Resets DB and MailHog', () => {
|
||||
cy.resetDB();
|
||||
cy.request('DELETE', `${mailhog}/api/v1/messages`);
|
||||
cy.loginAndVisit('/admin/campaigns');
|
||||
});
|
||||
|
||||
it('Embeds a rich text image as a MIME/CID part', () => richtext('embedrich', true, true));
|
||||
it('Sends a rich text image as a plain URL', () => richtext('plainrich', false, false));
|
||||
it('Embeds a visual image as a MIME/CID part', () => visual('embedvis', true));
|
||||
it('Sends a visual image as a plain URL', () => visual('plainvis', false));
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
describe('Dashboard', () => {
|
||||
it('Opens dashboard', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/');
|
||||
|
||||
// List counts.
|
||||
cy.get('[data-cy=lists] .title').contains('2');
|
||||
cy.get('[data-cy=lists]')
|
||||
.and('contain', '1 Public')
|
||||
.and('contain', '1 Private')
|
||||
.and('contain', '1 Single opt-in')
|
||||
.and('contain', '1 Double opt-in');
|
||||
|
||||
// Campaign counts.
|
||||
cy.get('[data-cy=campaigns] .title').contains('1');
|
||||
cy.get('[data-cy=campaigns-draft]').contains('1');
|
||||
|
||||
// Subscriber counts.
|
||||
cy.get('[data-cy=subscribers] .title').contains('2');
|
||||
cy.get('[data-cy=subscribers]')
|
||||
.should('contain', '0 Blocklisted')
|
||||
.and('contain', '0 Orphans');
|
||||
|
||||
// Message count.
|
||||
cy.get('[data-cy=messages] .title').contains('0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
describe('Forms', () => {
|
||||
it('Opens forms page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/lists/forms');
|
||||
});
|
||||
|
||||
it('Checks public lists', () => {
|
||||
cy.get('ul[data-cy=lists] li')
|
||||
.should('contain', 'Opt-in list')
|
||||
.its('length')
|
||||
.should('eq', 1);
|
||||
|
||||
cy.get('[data-cy=form] [role=textbox]').should('not.exist');
|
||||
});
|
||||
|
||||
it('Selects public list', () => {
|
||||
// Click the list checkbox.
|
||||
cy.get('ul[data-cy=lists] .checkbox').click();
|
||||
|
||||
// Check that the ID of the list in the checkbox appears in the HTML.
|
||||
cy.get('ul[data-cy=lists] input').then(($inp) => {
|
||||
cy.get('[role=textbox]').contains($inp.val());
|
||||
});
|
||||
|
||||
// Click the list checkbox.
|
||||
cy.get('ul[data-cy=lists] .checkbox').click();
|
||||
cy.get('[data-cy=form] pre').should('not.exist');
|
||||
});
|
||||
|
||||
it('Subscribes from public form page', () => {
|
||||
// Create a public test list.
|
||||
cy.request('POST', `${apiUrl}/api/lists`, { name: 'test-list', type: 'public', optin: 'single' });
|
||||
|
||||
// Open the public page and subscribe to alternating lists multiple times.
|
||||
// There should be no errors and two new subscribers should be subscribed to two lists.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
for (let j = 0; j < 2; j++) {
|
||||
cy.loginAndVisit(`${apiUrl}/subscription/form`);
|
||||
cy.get('input[name=email]').clear().type(`test${i}@test.com`);
|
||||
cy.get('input[name=name]').clear().type(`test${i}`);
|
||||
cy.get('input[type=checkbox]').eq(j).click();
|
||||
cy.get('button').click();
|
||||
cy.wait(250);
|
||||
cy.get('.wrap').contains(/has been sent|successfully|retry/); // If SMTP is not configured, it shows retry message.
|
||||
}
|
||||
}
|
||||
|
||||
// Verify form subscriptions.
|
||||
cy.request(`${apiUrl}/api/subscribers`).should((response) => {
|
||||
const { data } = response.body;
|
||||
|
||||
// Two new + two dummy subscribers that are there by default.
|
||||
expect(data.total).to.equal(4);
|
||||
|
||||
// The two new subscribers should each have two list subscriptions.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
expect(data.results.find((s) => s.email === `test${i}@test.com`).lists.length).to.equal(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('Unsubscribes', () => {
|
||||
// Add all lists to the dummy campaign.
|
||||
cy.request('PUT', `${apiUrl}/api/campaigns/1`, { lists: [2] });
|
||||
|
||||
cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
|
||||
const subUUID = response.body.data.results[0].uuid;
|
||||
|
||||
cy.request('GET', `${apiUrl}/api/campaigns`).then((response) => {
|
||||
const campUUID = response.body.data.results[0].uuid;
|
||||
cy.loginAndVisit(`${apiUrl}/subscription/${campUUID}/${subUUID}`);
|
||||
});
|
||||
});
|
||||
|
||||
cy.wait(500);
|
||||
|
||||
// Unsubscribe from one list.
|
||||
cy.get('button').click();
|
||||
cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data.results[0].lists.find((s) => s.id === 2).subscription_status).to.equal('unsubscribed');
|
||||
expect(data.results[0].lists.find((s) => s.id === 3).subscription_status).to.equal('unconfirmed');
|
||||
});
|
||||
|
||||
// Go back.
|
||||
cy.url().then((u) => {
|
||||
cy.loginAndVisit(u);
|
||||
});
|
||||
|
||||
// Unsubscribe from all.
|
||||
cy.get('#privacy-blocklist').click();
|
||||
cy.get('button').click();
|
||||
|
||||
cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data.results[0].status).to.equal('blocklisted');
|
||||
expect(data.results[0].lists.find((s) => s.id === 2).subscription_status).to.equal('unsubscribed');
|
||||
expect(data.results[0].lists.find((s) => s.id === 3).subscription_status).to.equal('unsubscribed');
|
||||
});
|
||||
});
|
||||
|
||||
it('Manages subscription preferences', () => {
|
||||
cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
|
||||
const subUUID = response.body.data.results[1].uuid;
|
||||
|
||||
cy.request('GET', `${apiUrl}/api/campaigns`).then((response) => {
|
||||
const campUUID = response.body.data.results[0].uuid;
|
||||
cy.loginAndVisit(`${apiUrl}/subscription/${campUUID}/${subUUID}?manage=1`);
|
||||
cy.get('a').contains('Manage').click();
|
||||
});
|
||||
});
|
||||
|
||||
// Change name and unsubscribe from one list.
|
||||
cy.get('input[name=name]').clear().type('new-name');
|
||||
cy.get('ul.lists input:first').click();
|
||||
cy.get('button:first').click();
|
||||
|
||||
cy.request('GET', `${apiUrl}/api/subscribers`).then((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data.results[1].name).to.equal('new-name');
|
||||
expect(data.results[1].lists.find((s) => s.id === 2).subscription_status).to.equal('unsubscribed');
|
||||
expect(data.results[1].lists.find((s) => s.id === 3).subscription_status).to.equal('unconfirmed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
describe('Import', () => {
|
||||
it('Opens import page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/subscribers/import');
|
||||
});
|
||||
|
||||
it('Imports subscribers', () => {
|
||||
const cases = [
|
||||
{
|
||||
chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'unconfirmed', overwrite: true, count: 102,
|
||||
},
|
||||
{
|
||||
chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'confirmed', subStatus: 'confirmed', overwrite: true, count: 102,
|
||||
},
|
||||
{
|
||||
chkMode: 'subscribe', status: 'enabled', chkSubStatus: 'unconfirmed', subStatus: 'confirmed', overwrite: false, count: 102,
|
||||
},
|
||||
{
|
||||
chkMode: 'blocklist', status: 'blocklisted', chkSubStatus: 'unsubscribed', subStatus: 'unsubscribed', overwrite: false, count: 102,
|
||||
},
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
cy.get(`[data-cy=check-${c.chkMode}] .check`).click();
|
||||
cy.get(`[data-cy=check-${c.chkSubStatus}] .check`).click();
|
||||
|
||||
if (c.overwrite) {
|
||||
cy.get('[data-cy=overwrite-user-info]').click();
|
||||
cy.get('[data-cy=overwrite-sub-status]').click();
|
||||
}
|
||||
|
||||
if (c.status === 'enabled') {
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
}
|
||||
|
||||
cy.fixture('subs.csv').then((data) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent: data.toString(),
|
||||
fileName: 'subs.csv',
|
||||
mimeType: 'text/csv',
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('button.is-primary').click();
|
||||
|
||||
// ONLY if .modal button.is-primary is present, click it.
|
||||
if (c.overwrite) {
|
||||
cy.get('.modal button.is-primary').click();
|
||||
}
|
||||
|
||||
cy.get('section.wrap .has-text-success');
|
||||
cy.get('button.is-primary').click();
|
||||
cy.wait(100);
|
||||
|
||||
// Verify that 100 (+2 default) subs are imported.
|
||||
cy.loginAndVisit('/admin/subscribers');
|
||||
cy.wait(100);
|
||||
cy.get('[data-cy=count]').then(($el) => {
|
||||
cy.expect(parseInt($el.text().trim())).to.equal(c.count);
|
||||
});
|
||||
|
||||
// Subscription status.
|
||||
// cy.get('tbody td[data-label=E-mail]').each(($el) => {
|
||||
// cy.wrap($el).find(`.tag.${c.subStatus}`);
|
||||
// });
|
||||
|
||||
cy.loginAndVisit('/admin/subscribers/import');
|
||||
cy.wait(100);
|
||||
});
|
||||
});
|
||||
|
||||
it('Imports subscribers incorrectly', () => {
|
||||
cy.wait(1000);
|
||||
cy.resetDB();
|
||||
cy.wait(1000);
|
||||
cy.loginAndVisit('/admin/subscribers/import');
|
||||
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
cy.get('input[name=delim]').clear().type('|');
|
||||
|
||||
cy.fixture('subs.csv').then((data) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent: data.toString(),
|
||||
fileName: 'subs.csv',
|
||||
mimeType: 'text/csv',
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('button.is-primary').click();
|
||||
cy.wait(250);
|
||||
cy.get('section.wrap .has-text-danger');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
describe('Lists', () => {
|
||||
it('Opens lists page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/lists');
|
||||
});
|
||||
|
||||
it('Counts subscribers in default lists', () => {
|
||||
cy.get('tbody td[data-label=Subscribers]').contains('1');
|
||||
});
|
||||
|
||||
it('Creates campaign for list', () => {
|
||||
cy.get('tbody a[data-cy=btn-campaign]').first().click();
|
||||
cy.location('pathname').should('contain', '/campaigns/new');
|
||||
cy.get('.list-tags .tag').contains('Default list');
|
||||
|
||||
cy.clickMenu('lists', 'all-lists');
|
||||
cy.get('.modal button.is-primary').click();
|
||||
});
|
||||
|
||||
it('Creates opt-in campaign for list', () => {
|
||||
cy.get('tbody a[data-cy=btn-send-optin-campaign]').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.location('pathname').should('contain', '/campaigns/2');
|
||||
cy.clickMenu('lists', 'all-lists');
|
||||
});
|
||||
|
||||
it('Checks individual subscribers in lists', () => {
|
||||
const subs = [{ listID: 1, email: 'john@example.com' },
|
||||
{ listID: 2, email: 'anon@example.com' }];
|
||||
|
||||
// Click on each list on the lists page, go the subscribers page
|
||||
// for that list, and check the subscriber details.
|
||||
subs.forEach((s, n) => {
|
||||
cy.get('tbody td[data-label=Subscribers] a').eq(n).click();
|
||||
cy.location('pathname').should('contain', `/subscribers/lists/${s.listID}`);
|
||||
cy.get('tbody tr').its('length').should('eq', 1);
|
||||
cy.get('tbody td[data-label="E-mail"]').contains(s.email);
|
||||
cy.clickMenu('lists', 'all-lists');
|
||||
});
|
||||
});
|
||||
|
||||
it('Edits lists', () => {
|
||||
// Open the edit popup and edit the default lists.
|
||||
cy.get('[data-cy=btn-edit]').each(($el, n) => {
|
||||
cy.wrap($el).click();
|
||||
cy.get('input[name=name]').clear().type(`list-${n}`);
|
||||
cy.get('select[name=type]').select('public');
|
||||
cy.get('select[name=optin]').select('double');
|
||||
cy.get('input[name=tags]').clear().type(`tag${n}{enter}`);
|
||||
cy.get('textarea[name=description]').clear().type(`desc${n}`);
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(100);
|
||||
});
|
||||
cy.wait(250);
|
||||
|
||||
// Confirm the edits.
|
||||
cy.get('tbody tr').each(($el, n) => {
|
||||
cy.wrap($el).find('td[data-label=Name]').contains(`list-${n}`);
|
||||
cy.wrap($el).find('.tags')
|
||||
.should('contain', 'test')
|
||||
.and('contain', `tag${n}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('Deletes lists', () => {
|
||||
// Delete all visible lists.
|
||||
cy.get('tbody tr').each(() => {
|
||||
cy.get('tbody a[data-cy=btn-delete]').first().click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
});
|
||||
|
||||
// Confirm deletion.
|
||||
cy.get('table tr.is-empty');
|
||||
});
|
||||
|
||||
// Add new lists.
|
||||
it('Adds new lists', () => {
|
||||
// Open the list form and create lists of multiple type/optin combinations.
|
||||
const types = ['private', 'public'];
|
||||
const optin = ['single', 'double'];
|
||||
|
||||
let n = 0;
|
||||
types.forEach((t) => {
|
||||
optin.forEach((o) => {
|
||||
const name = `list-${t}-${o}-${n}`;
|
||||
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=name]').type(name);
|
||||
cy.get('select[name=type]').select(t);
|
||||
cy.get('select[name=optin]').select(o);
|
||||
cy.get('input[name=tags]').type(`tag${n}{enter}${t}{enter}${o}{enter}`);
|
||||
cy.get('textarea[name=description]').clear().type(`desc-${t}-${n}`);
|
||||
cy.get('[data-cy=btn-save]').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] .tag[data-cy=type-${t}]`);
|
||||
cy.get(`${tr} td[data-label=Type] .tag[data-cy=optin-${o}]`);
|
||||
n++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Searches lists', () => {
|
||||
cy.get('[data-cy=query]').clear().type('list-public-single-2{enter}');
|
||||
cy.wait(200);
|
||||
cy.get('tbody tr').its('length').should('eq', 1);
|
||||
cy.get('tbody td[data-label="Name"]').first().contains('list-public-single-2');
|
||||
cy.get('[data-cy=query]').clear().type('{enter}');
|
||||
});
|
||||
|
||||
// Sort lists by clicking on various headers. At this point, there should be four
|
||||
// lists with IDs = [3, 4, 5, 6]. Sort the items be columns and match them with
|
||||
// the expected order of IDs.
|
||||
it('Sorts lists', () => {
|
||||
cy.sortTable('thead th.cy-name', [4, 3, 6, 5]);
|
||||
cy.sortTable('thead th.cy-name', [5, 6, 3, 4]);
|
||||
|
||||
cy.sortTable('thead th.cy-type', [3, 4, 5, 6]);
|
||||
cy.sortTable('thead th.cy-type', [6, 5, 4, 3]);
|
||||
|
||||
cy.sortTable('thead th.cy-created_at', [3, 4, 5, 6]);
|
||||
cy.sortTable('thead th.cy-created_at', [6, 5, 4, 3]);
|
||||
|
||||
cy.sortTable('thead th.cy-updated_at', [3, 4, 5, 6]);
|
||||
cy.sortTable('thead th.cy-updated_at', [6, 5, 4, 3]);
|
||||
});
|
||||
|
||||
it('Opens forms page', () => {
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
cy.loginAndVisit(`${apiUrl}/subscription/form`);
|
||||
cy.get('ul li').its('length').should('eq', 2);
|
||||
|
||||
const cases = [
|
||||
{ name: 'list-public-single-2', description: 'desc-public-2' },
|
||||
{ name: 'list-public-double-3', description: 'desc-public-3' },
|
||||
];
|
||||
|
||||
cases.forEach((c, n) => {
|
||||
cy.get('ul li').eq(n).then(($el) => {
|
||||
cy.wrap($el).get('label').contains(c.name);
|
||||
cy.wrap($el).get('.description').contains(c.description);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Bulk deletes lists', () => {
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
// Create 30 in a loop.
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
cy.request('POST', `${apiUrl}/api/lists`, { name: `test-list-${i}`, type: 'public', optin: 'single' });
|
||||
}
|
||||
|
||||
cy.loginAndVisit('/admin/lists');
|
||||
|
||||
// Bulk delete with the `all` flag.
|
||||
cy.window().scrollTo('top');
|
||||
cy.wait(500);
|
||||
cy.get('thead input[type="checkbox"]').click({ force: true });
|
||||
cy.get('a[data-cy=select-all-lists]').click();
|
||||
cy.get('a[data-cy=btn-delete-lists]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.get('table tr.is-empty');
|
||||
|
||||
// Bulk delete with the selected IDs.
|
||||
// Create 5 lists in a loop.
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
cy.request('POST', `${apiUrl}/api/lists`, { name: `test-list-bulk-${i}`, type: 'public', optin: 'single' });
|
||||
}
|
||||
|
||||
cy.visit('/admin/lists');
|
||||
cy.wait(500);
|
||||
cy.get('thead input[type="checkbox"]').click({ force: true });
|
||||
cy.get('a[data-cy=btn-delete-lists]').click();
|
||||
cy.get('.modal button.is-primary:eq(0)').click();
|
||||
cy.get('table tr.is-empty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
const rootURL = 'http://127.0.0.1:9000';
|
||||
const faveURL = 'http://127.0.0.1:9000/public/static/logo.png';
|
||||
|
||||
describe('Settings', () => {
|
||||
it('Opens settings page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/settings');
|
||||
});
|
||||
|
||||
it('Changes some settings', () => {
|
||||
cy.get('.b-tabs nav a').eq(0).click();
|
||||
|
||||
cy.get('input[name="app.root_url"]').clear().type(rootURL);
|
||||
cy.get('input[name="app.favicon_url"]').type(faveURL);
|
||||
cy.get('.b-tabs nav a').eq(1).click();
|
||||
cy.get('.tab-item:visible').find('.field').first()
|
||||
.find('button')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Enable / disable SMTP and delete one.
|
||||
cy.get('.b-tabs nav a').eq(5).click();
|
||||
cy.get('.tab-item:visible [data-cy=btn-enable-smtp]').eq(1).click();
|
||||
cy.get('.tab-item:visible [data-cy=btn-delete-smtp]').first().click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
|
||||
cy.waitForBackend();
|
||||
});
|
||||
|
||||
it('Verify settings change', () => {
|
||||
// Verify the changes.
|
||||
cy.request(`${apiUrl}/api/settings`).should((response) => {
|
||||
const { data } = response.body;
|
||||
expect(data['app.root_url']).to.equal(rootURL);
|
||||
expect(data['app.favicon_url']).to.equal(faveURL);
|
||||
expect(data['app.concurrency']).to.equal(9);
|
||||
|
||||
expect(data.smtp.length).to.equal(1);
|
||||
expect(data.smtp[0].enabled).to.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
describe('Subscribers', () => {
|
||||
it('Opens subscribers page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/subscribers');
|
||||
});
|
||||
|
||||
it('Counts subscribers', () => {
|
||||
cy.get('tbody td[data-label=E-mail]').its('length').should('eq', 2);
|
||||
});
|
||||
|
||||
it('Searches subscribers', () => {
|
||||
const cases = [
|
||||
{ value: 'john{enter}', count: 1, contains: 'john@example.com' },
|
||||
{ value: 'anon{enter}', count: 1, contains: 'anon@example.com' },
|
||||
{ value: '{enter}', count: 2, contains: null },
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
cy.get('[data-cy=search]').clear().type(c.value);
|
||||
cy.get('tbody td[data-label=E-mail]').its('length').should('eq', c.count);
|
||||
if (c.contains) {
|
||||
cy.get('tbody td[data-label=E-mail]').contains(c.contains);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('Exports subscribers', () => {
|
||||
const cases = [
|
||||
{
|
||||
listIDs: [], ids: [], query: '', length: 3,
|
||||
},
|
||||
{
|
||||
listIDs: [], ids: [], query: "name ILIKE '%anon%'", length: 2,
|
||||
},
|
||||
{
|
||||
listIDs: [], ids: [], query: "name like 'nope'", length: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// listIDs[] and ids[] are unused for now as Cypress doesn't support encoding of arrays in `qs`.
|
||||
cases.forEach((c) => {
|
||||
cy.request({ url: `${apiUrl}/api/subscribers/export`, qs: { query: c.query, list_id: c.listIDs, id: c.ids } }).then((resp) => {
|
||||
cy.expect(resp.body.trim().split('\n')).to.have.lengthOf(c.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Advanced searches subscribers', () => {
|
||||
cy.get('[data-cy=btn-advanced-search]').click();
|
||||
|
||||
const cases = [
|
||||
{ value: 'subscribers.attribs->>\'city\'=\'Bengaluru\'', count: 2 },
|
||||
{ value: 'subscribers.attribs->>\'city\'=\'Bengaluru\' AND id=1', count: 1 },
|
||||
{ value: '(subscribers.attribs->>\'good\')::BOOLEAN = true AND name like \'Anon%\'', count: 1 },
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
cy.get('[data-cy=query]').clear().type(c.value);
|
||||
cy.get('[data-cy=btn-query]').click();
|
||||
cy.get('tbody td[data-label=E-mail]').its('length').should('eq', c.count);
|
||||
});
|
||||
|
||||
cy.get('[data-cy=btn-query-reset]').click();
|
||||
cy.wait(1000);
|
||||
cy.get('tbody td[data-label=E-mail]').its('length').should('eq', 2);
|
||||
});
|
||||
|
||||
it('Does bulk subscriber list add and remove', () => {
|
||||
const cases = [
|
||||
// radio: action to perform, rows: table rows to select and perform on: [expected statuses of those rows after thea action]
|
||||
{ radio: 'check-list-add', lists: [0, 1], rows: { 0: ['confirmed', 'confirmed'] } },
|
||||
{ radio: 'check-list-unsubscribe', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unsubscribed'] } },
|
||||
{ radio: 'check-list-remove', lists: [0, 1], rows: { 1: [] } },
|
||||
{ radio: 'check-list-add', lists: [0, 1], rows: { 0: ['unsubscribed', 'unsubscribed'], 1: ['unconfirmed', 'unconfirmed'] } },
|
||||
{ radio: 'check-list-remove', lists: [0], rows: { 0: ['unsubscribed'] } },
|
||||
{ radio: 'check-list-add', lists: [0], rows: { 0: ['unconfirmed', 'unsubscribed'] } },
|
||||
];
|
||||
|
||||
cases.forEach((c, n) => {
|
||||
// Select one of the 2 subscribers in the table.
|
||||
Object.keys(c.rows).forEach((r) => {
|
||||
cy.get('tbody td.checkbox-cell .checkbox').eq(r).click();
|
||||
});
|
||||
|
||||
// Open the 'manage lists' modal.
|
||||
cy.get('[data-cy=btn-manage-lists]').click();
|
||||
|
||||
// Check both lists in the modal.
|
||||
c.lists.forEach((l) => {
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
});
|
||||
|
||||
// Select the radio option in the modal.
|
||||
cy.get(`[data-cy=${c.radio}] .check`).click();
|
||||
|
||||
// For the first test, check the optin preconfirm box.
|
||||
if (n === 0) {
|
||||
cy.get('[data-cy=preconfirm]').click();
|
||||
}
|
||||
|
||||
// Save.
|
||||
cy.get('.modal button.is-primary').click();
|
||||
|
||||
// Check the status of the lists on the subscriber.
|
||||
Object.keys(c.rows).forEach((r) => {
|
||||
cy.get('tbody td[data-label=E-mail]').eq(r).find('.tags').then(($el) => {
|
||||
cy.wrap($el).find('.tag').should('have.length', c.rows[r].length);
|
||||
c.rows[r].forEach((status, n) => {
|
||||
// eg: .tag(n).unconfirmed
|
||||
cy.wrap($el).find('.tag').eq(n).should('have.class', status);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Resets subscribers page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/subscribers');
|
||||
});
|
||||
|
||||
it('Edits subscribers', () => {
|
||||
const status = ['enabled', 'blocklisted'];
|
||||
const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
|
||||
|
||||
// Collect values being edited on each sub to confirm the changes in the next step
|
||||
// index by their ID shown in the modal.
|
||||
const rows = {};
|
||||
|
||||
// Open the edit popup and edit the default lists.
|
||||
cy.get('[data-cy=btn-edit]').each(($el, n) => {
|
||||
const email = `email-${n}@EMAIL.com`;
|
||||
const name = `name-${n}`;
|
||||
|
||||
// Open the edit modal.
|
||||
cy.wrap($el).click();
|
||||
|
||||
// Get the ID from the header and proceed to fill the form.
|
||||
let id = 0;
|
||||
cy.get('[data-cy=id]').then(($el) => {
|
||||
id = parseInt($el.text());
|
||||
|
||||
cy.get('input[name=email]').clear().type(email);
|
||||
cy.get('input[name=name]').clear().type(name);
|
||||
|
||||
if (status[n] === 'blocklisted') {
|
||||
cy.get('select[name=status]').select(status[n]);
|
||||
}
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
|
||||
cy.get('.modal-card-foot button[type=submit]').click();
|
||||
|
||||
rows[id] = { email, name, status: status[n] };
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm the edits on the table.
|
||||
cy.wait(500);
|
||||
cy.log(rows);
|
||||
cy.get('tbody tr').each(($el) => {
|
||||
cy.wrap($el).find('td[data-id]').invoke('attr', 'data-id').then((idStr) => {
|
||||
const id = parseInt(idStr);
|
||||
cy.wrap($el).find('td[data-label=E-mail]').contains(rows[id].email.toLowerCase());
|
||||
cy.wrap($el).find('td[data-label=Name]').contains(rows[id].name);
|
||||
|
||||
if (rows[id].status === 'blocklisted') {
|
||||
cy.wrap($el).find('[data-cy=blocklisted]');
|
||||
}
|
||||
|
||||
// Both lists on the enabled sub should be 'unconfirmed' and the blocklisted one, 'unsubscribed.'
|
||||
cy.wrap($el).find(`.tags .${rows[id].status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
|
||||
.its('length').should('eq', 2);
|
||||
cy.wrap($el).find('td[data-label=Lists]').then((l) => {
|
||||
cy.expect(parseInt(l.text().trim())).to.equal(rows[id].status === 'blocklisted' ? 0 : 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Deletes subscribers', () => {
|
||||
// Delete all visible lists.
|
||||
cy.get('tbody tr').each(() => {
|
||||
cy.get('tbody a[data-cy=btn-delete]').first().click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
});
|
||||
|
||||
// Confirm deletion.
|
||||
cy.get('table tr.is-empty');
|
||||
});
|
||||
|
||||
it('Creates new subscribers', () => {
|
||||
const statuses = ['enabled', 'blocklisted'];
|
||||
const lists = [[1], [2], [1, 2]];
|
||||
const json = '{"string": "hello", "ints": [1,2,3], "null": null, "sub": {"bool": true}}';
|
||||
|
||||
// Cycle through each status and each list ID combination and create subscribers.
|
||||
const n = 0;
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const email = `email-${n}@EMAIL.com`;
|
||||
const name = `name-${n}`;
|
||||
const status = statuses[(n + 1) % statuses.length];
|
||||
const list = lists[(n + 1) % lists.length];
|
||||
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=email]').type(email);
|
||||
cy.get('input[name=name]').type(name);
|
||||
cy.get('select[name=status]').select(status);
|
||||
|
||||
list.forEach((l) => {
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
});
|
||||
cy.get('textarea[name=attribs]').clear().type(json, { parseSpecialCharSequences: false, delay: 0 });
|
||||
cy.get('.modal-card-foot button[type=submit]').click();
|
||||
|
||||
// Confirm the addition by inspecting the newly created list row,
|
||||
// which is always the first row in the table.
|
||||
cy.wait(250);
|
||||
const tr = cy.get('tbody tr:nth-child(1)').then(($el) => {
|
||||
cy.wrap($el).find('td[data-label=E-mail]').contains(email.toLowerCase());
|
||||
cy.wrap($el).find('td[data-label=Name]').contains(name);
|
||||
|
||||
if (status === 'blocklisted') {
|
||||
cy.wrap($el).find('[data-cy=blocklisted]');
|
||||
}
|
||||
cy.wrap($el).find(`.tags .${status === 'enabled' ? 'unconfirmed' : 'unsubscribed'}`)
|
||||
.its('length').should('eq', list.length);
|
||||
cy.wrap($el).find('td[data-label=Lists]').then((l) => {
|
||||
cy.expect(parseInt(l.text().trim())).to.equal(status === 'blocklisted' ? 0 : list.length);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('Sorts subscribers', () => {
|
||||
const asc = [3, 4, 5, 6, 7, 8];
|
||||
const desc = [8, 7, 6, 5, 4, 3];
|
||||
const cases = ['cy-email', 'cy-name', 'cy-created_at', 'cy-updated_at'];
|
||||
|
||||
cases.forEach((c) => {
|
||||
cy.sortTable(`thead th.${c}`, asc);
|
||||
cy.wait(250);
|
||||
cy.sortTable(`thead th.${c}`, desc);
|
||||
cy.wait(250);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Domain blocklist', () => {
|
||||
it('Opens settings page', () => {
|
||||
cy.resetDB();
|
||||
});
|
||||
|
||||
it('Add domains to blocklist', () => {
|
||||
cy.loginAndVisit('/admin/settings');
|
||||
cy.get('.b-tabs nav a').eq(2).click();
|
||||
cy.get('textarea[name="privacy.domain_blocklist"]').clear().type('ban.net\n\nBaN.OrG\n\nban.com\n\n');
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.waitForBackend();
|
||||
});
|
||||
|
||||
it('Try subscribing via public page', () => {
|
||||
cy.visit(`${apiUrl}/subscription/form`);
|
||||
cy.get('input[name=email]').clear().type('test@noban.net');
|
||||
cy.get('button[type=submit]').click();
|
||||
cy.get('h2').contains('Subscribe');
|
||||
|
||||
cy.visit(`${apiUrl}/subscription/form`);
|
||||
cy.get('input[name=email]').clear().type('test@ban.net');
|
||||
cy.get('button[type=submit]').click();
|
||||
cy.get('h2').contains('Error');
|
||||
});
|
||||
|
||||
// Post to the admin API.
|
||||
it('Try via admin API', () => {
|
||||
cy.wait(1000);
|
||||
|
||||
// Add non-banned domain.
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `${apiUrl}/api/subscribers`,
|
||||
failOnStatusCode: true,
|
||||
body: {
|
||||
email: 'test1@noban.net', name: 'test', lists: [1], status: 'enabled',
|
||||
},
|
||||
}).should((response) => {
|
||||
expect(response.status).to.equal(200);
|
||||
});
|
||||
|
||||
// Add banned domain.
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `${apiUrl}/api/subscribers`,
|
||||
failOnStatusCode: false,
|
||||
body: {
|
||||
email: 'test1@ban.com', name: 'test', lists: [1], status: 'enabled',
|
||||
},
|
||||
}).should((response) => {
|
||||
expect(response.status).to.equal(400);
|
||||
});
|
||||
|
||||
// Modify an existinb subscriber to a banned domain.
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `${apiUrl}/api/subscribers/1`,
|
||||
failOnStatusCode: false,
|
||||
body: {
|
||||
email: 'test3@ban.org', name: 'test', lists: [1], status: 'enabled',
|
||||
},
|
||||
}).should((response) => {
|
||||
expect(response.status).to.equal(400);
|
||||
});
|
||||
});
|
||||
|
||||
it('Try via import', () => {
|
||||
cy.loginAndVisit('/admin/subscribers/import');
|
||||
cy.get('.list-selector input').click();
|
||||
cy.get('.list-selector .autocomplete a').first().click();
|
||||
|
||||
cy.fixture('subs-domain-blocklist.csv').then((data) => {
|
||||
cy.get('input[type="file"]').attachFile({
|
||||
fileContent: data.toString(),
|
||||
fileName: 'subs.csv',
|
||||
mimeType: 'text/csv',
|
||||
});
|
||||
});
|
||||
|
||||
cy.get('button.is-primary').click();
|
||||
cy.get('section.wrap .has-text-success');
|
||||
// cy.get('button.is-primary').click();
|
||||
cy.get('.log-view').should('contain', 'ban1-import@BAN.net').and('contain', 'ban2-import@ban.ORG');
|
||||
cy.wait(100);
|
||||
});
|
||||
|
||||
it('Clear blocklist and try', () => {
|
||||
cy.loginAndVisit('/admin/settings');
|
||||
cy.get('.b-tabs nav a').eq(2).click();
|
||||
cy.get('textarea[name="privacy.domain_blocklist"]').clear();
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.waitForBackend();
|
||||
|
||||
// Add banned domain.
|
||||
cy.request({
|
||||
method: 'POST',
|
||||
url: `${apiUrl}/api/subscribers`,
|
||||
failOnStatusCode: true,
|
||||
body: {
|
||||
email: 'test4@BAN.com', name: 'test', lists: [1], status: 'enabled',
|
||||
},
|
||||
}).should((response) => {
|
||||
expect(response.status).to.equal(200);
|
||||
});
|
||||
|
||||
// Modify an existinb subscriber to a banned domain.
|
||||
cy.request({
|
||||
method: 'PUT',
|
||||
url: `${apiUrl}/api/subscribers/1`,
|
||||
failOnStatusCode: true,
|
||||
body: {
|
||||
email: 'test4@BAN.org', name: 'test', lists: [1], status: 'enabled',
|
||||
},
|
||||
}).should((response) => {
|
||||
expect(response.status).to.equal(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
describe('Templates', () => {
|
||||
it('Opens templates page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/campaigns/templates');
|
||||
});
|
||||
|
||||
it('Counts default templates', () => {
|
||||
cy.get('tbody td[data-label=Name]').should('have.length', 4);
|
||||
});
|
||||
|
||||
it('Clones campaign template', () => {
|
||||
cy.get('[data-cy=btn-clone]').first().click();
|
||||
cy.get('.modal input').clear().type('cloned campaign').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(250);
|
||||
|
||||
// Verify the newly created row.
|
||||
cy.get('tbody td[data-label="Name"]').contains('td', 'cloned campaign');
|
||||
});
|
||||
|
||||
it('Clones tx template', () => {
|
||||
cy.get('tbody td[data-label="Name"]').contains('td', 'Sample transactional template').then((el) => {
|
||||
cy.wrap(el).parent().find('[data-cy=btn-clone]').click();
|
||||
cy.get('.modal input').clear().type('cloned tx').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(250);
|
||||
});
|
||||
|
||||
// Verify the newly created row.
|
||||
cy.get('tbody td[data-label="Name"]').contains('td', 'cloned tx');
|
||||
});
|
||||
|
||||
it('Edits template', () => {
|
||||
cy.get('tbody td.actions [data-cy=btn-edit]').first().click();
|
||||
cy.wait(250);
|
||||
cy.get('input[name=name]').clear().type('edited');
|
||||
|
||||
const htmlBody = '<span>test</span><div class="wrap">{{ template "content" . }}</div>';
|
||||
cy.get('[role="textbox"]').invoke('text', htmlBody);
|
||||
|
||||
cy.get('.modal-card-foot button.is-primary').click();
|
||||
cy.wait(250);
|
||||
cy.get('tbody td[data-label="Name"] a').contains('edited');
|
||||
});
|
||||
|
||||
it('Previews campaign templates', () => {
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
// Edited one should have a bare body.
|
||||
cy.request(`${apiUrl}/api/templates/1/preview`).then((resp) => {
|
||||
expect(resp.body).to.contain('test');
|
||||
expect(resp.body).to.contain('Hi there');
|
||||
});
|
||||
|
||||
// Cloned one should have the full template with wrap and unsubscribe.
|
||||
cy.request(`${apiUrl}/api/templates/5/preview`).then((resp) => {
|
||||
expect(resp.body).to.contain('Hi there');
|
||||
expect(resp.body).to.contain('Unsubscribe');
|
||||
});
|
||||
});
|
||||
|
||||
it('Previews tx templates', () => {
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
// Cloned tx template.
|
||||
cy.request(`${apiUrl}/api/templates/6/preview`).then((resp) => {
|
||||
expect(resp.body).to.contain('Order number');
|
||||
});
|
||||
});
|
||||
|
||||
it('Sets default', () => {
|
||||
cy.get('tbody td[data-label="Name"]').contains('td', 'cloned campaign').then((el) => {
|
||||
cy.wrap(el).parent().find('[data-cy=btn-set-default]').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
});
|
||||
|
||||
// The original default shouldn't have default and the new one should have.
|
||||
cy.get('tbody').contains('td', 'edited').parent().find('[data-cy=btn-delete]')
|
||||
.should('exist');
|
||||
cy.get('tbody').contains('td', 'cloned campaign').parent().find('[data-cy=btn-delete]')
|
||||
.should('not.exist');
|
||||
});
|
||||
|
||||
it('Deletes template', () => {
|
||||
cy.wait(250);
|
||||
|
||||
['Default archive template', 'Sample transactional template'].forEach((t) => {
|
||||
cy.get('tbody td[data-label="Name"]').contains('td', t).then((el) => {
|
||||
cy.wrap(el).parent().find('[data-cy=btn-delete]').click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
});
|
||||
cy.wait(250);
|
||||
});
|
||||
|
||||
cy.get('tbody td.actions').should('have.length', 4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
const apiUrl = Cypress.env('apiUrl');
|
||||
|
||||
describe('First time user setup', () => {
|
||||
it('Sets up the superadmin user', () => {
|
||||
cy.resetDBBlank();
|
||||
cy.visit('/admin/login');
|
||||
|
||||
cy.get('input[name=email]').type('super@domain');
|
||||
cy.get('input[name=username]').type('super');
|
||||
cy.get('input[name=password]').type('super123');
|
||||
cy.get('input[name=password2]').type('super123');
|
||||
cy.get('button[type=submit]').click();
|
||||
cy.wait(500);
|
||||
cy.visit('/admin/users');
|
||||
|
||||
cy.get('[data-cy=btn-edit]').first().click();
|
||||
cy.get('select[name=user_role]').should('have.value', '1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User roles', () => {
|
||||
it('Opens user roles page', () => {
|
||||
cy.resetDB();
|
||||
cy.loginAndVisit('/admin/users/roles/users');
|
||||
});
|
||||
|
||||
it('Adds new roles', () => {
|
||||
// first - no global list perms.
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=name]').type('first');
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
|
||||
// second - all perms.
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=name]').type('second');
|
||||
cy.get('input[type=checkbox]').each((e) => {
|
||||
cy.get(e).check({ force: true });
|
||||
});
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(200);
|
||||
});
|
||||
|
||||
it('Edits role', () => {
|
||||
cy.get('[data-cy=btn-edit]').first().click();
|
||||
cy.get('input[value="users:get"]').check({ force: true });
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
});
|
||||
|
||||
it('Deletes role', () => {
|
||||
cy.get('[data-cy=btn-clone]').last().click();
|
||||
cy.get('.modal-card-foot button.is-primary').click();
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy=btn-delete]').last().click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
|
||||
cy.get('tbody tr').should('have.length', 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('List roles', () => {
|
||||
it('Opens roles page', () => {
|
||||
cy.loginAndVisit('/admin/users/roles/lists');
|
||||
});
|
||||
|
||||
it('Adds new roles', () => {
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=name]').type('first');
|
||||
cy.get('.box button.is-primary').click();
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=name]').type('second');
|
||||
cy.get('.box button.is-primary').click();
|
||||
cy.get('.box button.is-primary').click();
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
cy.wait(500);
|
||||
});
|
||||
|
||||
it('Edits role', () => {
|
||||
cy.get('[data-cy=btn-edit]').eq(1).click();
|
||||
|
||||
// Uncheck "manage" permission on the second item.
|
||||
cy.get('input[type=checkbox]').eq(3).uncheck({ force: true });
|
||||
cy.get('[data-cy=btn-save]').click();
|
||||
});
|
||||
|
||||
it('Deletes role', () => {
|
||||
cy.get('[data-cy=btn-clone]').last().click();
|
||||
cy.get('.modal-card-foot button.is-primary').click();
|
||||
cy.wait(500);
|
||||
cy.get('[data-cy=btn-delete]').last().click();
|
||||
cy.get('.modal button.is-primary').click();
|
||||
|
||||
cy.get('tbody tr').should('have.length', 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Users ', () => {
|
||||
it('Opens users page', () => {
|
||||
cy.loginAndVisit('/admin/users');
|
||||
});
|
||||
|
||||
it('Adds new users', () => {
|
||||
['first', 'second', 'third'].forEach((name) => {
|
||||
cy.get('[data-cy=btn-new]').click();
|
||||
cy.get('input[name=username]').type(name);
|
||||
cy.get('input[name=name]').type(name);
|
||||
cy.get('input[name=email]').type(`${name}@domain`);
|
||||
cy.get('input[name=password_login]').check({ force: true });
|
||||
cy.get('input[name=password]').type(`${name}000000`);
|
||||
cy.get('input[name=password2]').type(`${name}000000`);
|
||||
|
||||
const role = name !== 'third' ? name : 'first';
|
||||
cy.get('select[name=user_role]').select(role);
|
||||
cy.get('select[name=list_role]').select(role);
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(500);
|
||||
});
|
||||
});
|
||||
|
||||
it('Edits user', () => {
|
||||
cy.get('[data-cy=btn-edit]').last().click();
|
||||
cy.get('input[name=password_login]').uncheck({ force: true });
|
||||
cy.get('select[name=user_role]').select('second');
|
||||
cy.get('select[name=list_role]').select('second');
|
||||
cy.get('.modal button.is-primary').click();
|
||||
cy.wait(500);
|
||||
|
||||
// Fetch the campaigns API and verfiy the values that couldn't be verified on the table UI.
|
||||
cy.request(`${apiUrl}/api/users/4`).should((response) => {
|
||||
const { data } = response.body;
|
||||
|
||||
expect(data.password_login).to.equal(false);
|
||||
expect(data.user_role.name).to.equal('second');
|
||||
expect(data.list_role.name).to.equal('second');
|
||||
});
|
||||
});
|
||||
|
||||
it('Deletes a user', () => {
|
||||
cy.get('[data-cy=btn-delete]').last().click();
|
||||
cy.get('.modal-card-foot button.is-primary').click();
|
||||
cy.wait(500);
|
||||
cy.get('tbody tr').should('have.length', 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login ', () => {
|
||||
it('Logs in as first', () => {
|
||||
cy.visit('/admin/login?next=/admin/lists');
|
||||
cy.get('input[name=username]').invoke('val', 'first');
|
||||
cy.get('input[name=password]').invoke('val', 'first000000');
|
||||
cy.get('button').click();
|
||||
|
||||
// first=only default list.
|
||||
cy.get('tbody tr').should('have.length', 1);
|
||||
cy.get('tbody td[data-label=Name]').contains('Default list');
|
||||
cy.get('[data-cy=btn-new]').should('not.exist');
|
||||
cy.get('[data-cy=btn-edit]').should('exist');
|
||||
cy.get('[data-cy=btn-delete]').should('exist');
|
||||
});
|
||||
|
||||
it('Logs in as second', () => {
|
||||
cy.visit('/admin/login?next=/admin/lists');
|
||||
cy.get('input[name=username]').invoke('val', 'second');
|
||||
cy.get('input[name=password]').invoke('val', 'second000000');
|
||||
cy.get('button').click();
|
||||
|
||||
// first=only default list.
|
||||
cy.get('tbody tr').should('have.length', 2);
|
||||
cy.get('tbody tr:nth-child(1) [data-cy=btn-edit]').should('exist');
|
||||
cy.get('tbody tr:nth-child(1) [data-cy=btn-delete]').should('exist');
|
||||
cy.get('tbody tr:nth-child(2) [data-cy=btn-edit]').should('exist');
|
||||
cy.get('tbody tr:nth-child(2) [data-cy=btn-delete]').should('exist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,5 @@
|
||||
email,name,attributes
|
||||
noban1-import@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
|
||||
ban1-import@BAN.net,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
|
||||
noban2-import1@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}"
|
||||
ban2-import@ban.ORG,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
|
||||
|
@@ -0,0 +1,101 @@
|
||||
email,name,attributes
|
||||
user0@mail.com,First0 Last0,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
|
||||
user1@mail.com,First1 Last1,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
|
||||
user2@mail.com,First2 Last2,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX70""}"
|
||||
user3@mail.com,First3 Last3,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX32""}"
|
||||
user4@mail.com,First4 Last4,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
|
||||
user5@mail.com,First5 Last5,"{""age"": 69, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}"
|
||||
user6@mail.com,First6 Last6,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}"
|
||||
user7@mail.com,First7 Last7,"{""age"": 56, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}"
|
||||
user8@mail.com,First8 Last8,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}"
|
||||
user9@mail.com,First9 Last9,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX66""}"
|
||||
user10@mail.com,First10 Last10,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}"
|
||||
user11@mail.com,First11 Last11,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX59""}"
|
||||
user12@mail.com,First12 Last12,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}"
|
||||
user13@mail.com,First13 Last13,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX96""}"
|
||||
user14@mail.com,First14 Last14,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}"
|
||||
user15@mail.com,First15 Last15,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}"
|
||||
user16@mail.com,First16 Last16,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}"
|
||||
user17@mail.com,First17 Last17,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX93""}"
|
||||
user18@mail.com,First18 Last18,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}"
|
||||
user19@mail.com,First19 Last19,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX21""}"
|
||||
user20@mail.com,First20 Last20,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX56""}"
|
||||
user21@mail.com,First21 Last21,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}"
|
||||
user22@mail.com,First22 Last22,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}"
|
||||
user23@mail.com,First23 Last23,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}"
|
||||
user24@mail.com,First24 Last24,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}"
|
||||
user25@mail.com,First25 Last25,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX80""}"
|
||||
user26@mail.com,First26 Last26,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}"
|
||||
user27@mail.com,First27 Last27,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}"
|
||||
user28@mail.com,First28 Last28,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX45""}"
|
||||
user29@mail.com,First29 Last29,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}"
|
||||
user30@mail.com,First30 Last30,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX27""}"
|
||||
user31@mail.com,First31 Last31,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX37""}"
|
||||
user32@mail.com,First32 Last32,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX50""}"
|
||||
user33@mail.com,First33 Last33,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX29""}"
|
||||
user34@mail.com,First34 Last34,"{""age"": 59, ""city"": ""Bangalore"", ""clientId"": ""DAXX95""}"
|
||||
user35@mail.com,First35 Last35,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
|
||||
user36@mail.com,First36 Last36,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
|
||||
user37@mail.com,First37 Last37,"{""age"": 36, ""city"": ""Bangalore"", ""clientId"": ""DAXX92""}"
|
||||
user38@mail.com,First38 Last38,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX48""}"
|
||||
user39@mail.com,First39 Last39,"{""age"": 23, ""city"": ""Bangalore"", ""clientId"": ""DAXX12""}"
|
||||
user40@mail.com,First40 Last40,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}"
|
||||
user41@mail.com,First41 Last41,"{""age"": 41, ""city"": ""Bangalore"", ""clientId"": ""DAXX51""}"
|
||||
user42@mail.com,First42 Last42,"{""age"": 22, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}"
|
||||
user43@mail.com,First43 Last43,"{""age"": 68, ""city"": ""Bangalore"", ""clientId"": ""DAXX58""}"
|
||||
user44@mail.com,First44 Last44,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX15""}"
|
||||
user45@mail.com,First45 Last45,"{""age"": 44, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}"
|
||||
user46@mail.com,First46 Last46,"{""age"": 42, ""city"": ""Bangalore"", ""clientId"": ""DAXX99""}"
|
||||
user47@mail.com,First47 Last47,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX39""}"
|
||||
user48@mail.com,First48 Last48,"{""age"": 57, ""city"": ""Bangalore"", ""clientId"": ""DAXX13""}"
|
||||
user49@mail.com,First49 Last49,"{""age"": 28, ""city"": ""Bangalore"", ""clientId"": ""DAXX97""}"
|
||||
user50@mail.com,First50 Last50,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}"
|
||||
user51@mail.com,First51 Last51,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}"
|
||||
user52@mail.com,First52 Last52,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX35""}"
|
||||
user53@mail.com,First53 Last53,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX67""}"
|
||||
user54@mail.com,First54 Last54,"{""age"": 25, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}"
|
||||
user55@mail.com,First55 Last55,"{""age"": 39, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}"
|
||||
user56@mail.com,First56 Last56,"{""age"": 53, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}"
|
||||
user57@mail.com,First57 Last57,"{""age"": 32, ""city"": ""Bangalore"", ""clientId"": ""DAXX36""}"
|
||||
user58@mail.com,First58 Last58,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX44""}"
|
||||
user59@mail.com,First59 Last59,"{""age"": 47, ""city"": ""Bangalore"", ""clientId"": ""DAXX65""}"
|
||||
user60@mail.com,First60 Last60,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}"
|
||||
user61@mail.com,First61 Last61,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX55""}"
|
||||
user62@mail.com,First62 Last62,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}"
|
||||
user63@mail.com,First63 Last63,"{""age"": 52, ""city"": ""Bangalore"", ""clientId"": ""DAXX83""}"
|
||||
user64@mail.com,First64 Last64,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX16""}"
|
||||
user65@mail.com,First65 Last65,"{""age"": 48, ""city"": ""Bangalore"", ""clientId"": ""DAXX54""}"
|
||||
user66@mail.com,First66 Last66,"{""age"": 35, ""city"": ""Bangalore"", ""clientId"": ""DAXX74""}"
|
||||
user67@mail.com,First67 Last67,"{""age"": 70, ""city"": ""Bangalore"", ""clientId"": ""DAXX22""}"
|
||||
user68@mail.com,First68 Last68,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX98""}"
|
||||
user69@mail.com,First69 Last69,"{""age"": 46, ""city"": ""Bangalore"", ""clientId"": ""DAXX24""}"
|
||||
user70@mail.com,First70 Last70,"{""age"": 58, ""city"": ""Bangalore"", ""clientId"": ""DAXX75""}"
|
||||
user71@mail.com,First71 Last71,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX57""}"
|
||||
user72@mail.com,First72 Last72,"{""age"": 63, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
|
||||
user73@mail.com,First73 Last73,"{""age"": 54, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}"
|
||||
user74@mail.com,First74 Last74,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}"
|
||||
user75@mail.com,First75 Last75,"{""age"": 61, ""city"": ""Bangalore"", ""clientId"": ""DAXX30""}"
|
||||
user76@mail.com,First76 Last76,"{""age"": 50, ""city"": ""Bangalore"", ""clientId"": ""DAXX28""}"
|
||||
user77@mail.com,First77 Last77,"{""age"": 62, ""city"": ""Bangalore"", ""clientId"": ""DAXX41""}"
|
||||
user78@mail.com,First78 Last78,"{""age"": 66, ""city"": ""Bangalore"", ""clientId"": ""DAXX18""}"
|
||||
user79@mail.com,First79 Last79,"{""age"": 40, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}"
|
||||
user80@mail.com,First80 Last80,"{""age"": 21, ""city"": ""Bangalore"", ""clientId"": ""DAXX72""}"
|
||||
user81@mail.com,First81 Last81,"{""age"": 43, ""city"": ""Bangalore"", ""clientId"": ""DAXX31""}"
|
||||
user82@mail.com,First82 Last82,"{""age"": 33, ""city"": ""Bangalore"", ""clientId"": ""DAXX89""}"
|
||||
user83@mail.com,First83 Last83,"{""age"": 38, ""city"": ""Bangalore"", ""clientId"": ""DAXX88""}"
|
||||
user84@mail.com,First84 Last84,"{""age"": 24, ""city"": ""Bangalore"", ""clientId"": ""DAXX77""}"
|
||||
user85@mail.com,First85 Last85,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX40""}"
|
||||
user86@mail.com,First86 Last86,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}"
|
||||
user87@mail.com,First87 Last87,"{""age"": 20, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}"
|
||||
user88@mail.com,First88 Last88,"{""age"": 45, ""city"": ""Bangalore"", ""clientId"": ""DAXX79""}"
|
||||
user89@mail.com,First89 Last89,"{""age"": 31, ""city"": ""Bangalore"", ""clientId"": ""DAXX11""}"
|
||||
user90@mail.com,First90 Last90,"{""age"": 51, ""city"": ""Bangalore"", ""clientId"": ""DAXX71""}"
|
||||
user91@mail.com,First91 Last91,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}"
|
||||
user92@mail.com,First92 Last92,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX20""}"
|
||||
user93@mail.com,First93 Last93,"{""age"": 67, ""city"": ""Bangalore"", ""clientId"": ""DAXX64""}"
|
||||
user94@mail.com,First94 Last94,"{""age"": 60, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}"
|
||||
user95@mail.com,First95 Last95,"{""age"": 64, ""city"": ""Bangalore"", ""clientId"": ""DAXX91""}"
|
||||
user96@mail.com,First96 Last96,"{""age"": 27, ""city"": ""Bangalore"", ""clientId"": ""DAXX53""}"
|
||||
user97@mail.com,First97 Last97,"{""age"": 29, ""city"": ""Bangalore"", ""clientId"": ""DAXX46""}"
|
||||
user98@mail.com,First98 Last98,"{""age"": 26, ""city"": ""Bangalore"", ""clientId"": ""DAXX49""}"
|
||||
user99@mail.com,First99 Last99,"{""age"": 49, ""city"": ""Bangalore"", ""clientId"": ""DAXX26""}"
|
||||
|
@@ -0,0 +1,50 @@
|
||||
/// <reference types="cypress" />
|
||||
|
||||
const { execSync, spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @type {Cypress.PluginConfig}
|
||||
*/
|
||||
module.exports = (on, config) => {
|
||||
const rootDir = path.resolve(__dirname, '..', '..', '..');
|
||||
|
||||
on('task', {
|
||||
// Kill eaglecast, reset the DB, and start the server in the background.
|
||||
resetServer({ blank = false } = {}) {
|
||||
try {
|
||||
execSync('pkill -9 eaglecast', { stdio: 'ignore' });
|
||||
} catch (e) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
// Run install.
|
||||
const env = blank
|
||||
? { ...process.env }
|
||||
: { ...process.env, EAGLECAST_ADMIN_USER: 'admin', EAGLECAST_ADMIN_PASSWORD: 'eaglecast' };
|
||||
|
||||
execSync('./eaglecast --install --yes', { cwd: rootDir, env, stdio: 'ignore' });
|
||||
|
||||
// Replace the first SMTP block with local MailHog.
|
||||
const smtpSQL = "UPDATE settings SET value = (SELECT jsonb_agg(smtp || jsonb_build_object('host','localhost','port',1025,'tls_type','none')) FROM jsonb_array_elements(value) AS smtp) WHERE key = 'smtp';";
|
||||
try {
|
||||
execSync('docker exec -i eaglecast_db psql -U eaglecast -d eaglecast', {
|
||||
input: smtpSQL,
|
||||
stdio: ['pipe', 'ignore', 'ignore'],
|
||||
});
|
||||
} catch (e) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
// Start the server.
|
||||
const child = spawn('./eaglecast', [], {
|
||||
cwd: rootDir,
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'cypress-file-upload';
|
||||
import 'cypress-wait-until';
|
||||
|
||||
Cypress.Commands.add('resetDB', () => {
|
||||
// Although cypress clearly states that a webserver should not be run
|
||||
// from within it, eaglecast is killed, the DB reset, and run again
|
||||
// in the background. If the DB is reset without restarting eaglecast,
|
||||
// the live Postgres connections in the app throw errors because the
|
||||
// schema changes midway.
|
||||
cy.task('resetServer');
|
||||
cy.waitForBackend();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('resetDBBlank', () => {
|
||||
cy.task('resetServer', { blank: true });
|
||||
cy.waitForBackend();
|
||||
});
|
||||
|
||||
// Takes a th class selector of a Buefy table, clicks it sorting the table,
|
||||
// then compares the values of [td.data-id] attri of all the rows in the
|
||||
// table against the given IDs, asserting the expected order of sort.
|
||||
Cypress.Commands.add('sortTable', (theadSelector, ordIDs) => {
|
||||
cy.get(theadSelector).click();
|
||||
cy.wait(250);
|
||||
cy.get('tbody td[data-id]').each(($el, index) => {
|
||||
expect(ordIDs[index]).to.equal(parseInt($el.attr('data-id')));
|
||||
});
|
||||
});
|
||||
|
||||
Cypress.Commands.add('loginAndVisit', (url) => {
|
||||
cy.visit(`/admin/login?next=${url}`);
|
||||
|
||||
const username = Cypress.env('EAGLECAST_ADMIN_USER') || 'admin';
|
||||
const password = Cypress.env('EAGLECAST_ADMIN_PASSWORD') || 'eaglecast';
|
||||
|
||||
// Fill the username and passowrd and login.
|
||||
cy.get('input[name=username]').invoke('val', username);
|
||||
cy.get('input[name=password]').invoke('val', password);
|
||||
|
||||
// Submit form.
|
||||
cy.get('button').click();
|
||||
});
|
||||
|
||||
Cypress.Commands.add('clickMenu', (...selectors) => {
|
||||
selectors.forEach((s) => {
|
||||
cy.get(`.menu a[data-cy="${s}"]`).click();
|
||||
});
|
||||
});
|
||||
|
||||
// https://www.nicknish.co/blog/cypress-targeting-elements-inside-iframes
|
||||
Cypress.Commands.add('iframe', { prevSubject: 'element' }, ($iframe, callback = () => { }) => cy
|
||||
.wrap($iframe)
|
||||
.should((iframe) => expect(iframe.contents().find('body')).to.exist)
|
||||
.then((iframe) => cy.wrap(iframe.contents().find('body')))
|
||||
.within({}, callback));
|
||||
|
||||
Cypress.Commands.add('waitForBackend', () => {
|
||||
// The server restarts after a 500ms delay on settings change.
|
||||
// Wait for the server to go down
|
||||
cy.wait(1000);
|
||||
|
||||
// Keep polling the public /health endpoint until the (new) server
|
||||
// is live. Use fetch() as cy.request() throws on ECONNREFUSED even with failOnStatusCode:false.
|
||||
cy.waitUntil(
|
||||
() => cy.wrap(null, { log: false }).then(() => fetch('/health')
|
||||
.then((res) => res.status === 200)
|
||||
.catch(() => false)),
|
||||
{
|
||||
timeout: 60000,
|
||||
interval: 2000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
if (err.hasOwnProperty('request')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import './commands';
|
||||
|
||||
beforeEach(() => {
|
||||
cy.intercept('GET', '/sockjs-node/**', (req) => {
|
||||
req.destroy();
|
||||
});
|
||||
|
||||
cy.intercept('GET', '/api/health', (req) => {
|
||||
req.reply({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
pkill -9 eaglecast
|
||||
cd ../
|
||||
./eaglecast --install --yes
|
||||
./eaglecast > /dev/null 2>/dev/null &
|
||||
@@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2022: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react-hooks', 'simple-import-sort'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', 'node_modules'],
|
||||
rules: {
|
||||
'simple-import-sort/imports': 'off',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Waypoint (Metaccountant, Inc.)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,10 @@
|
||||
# @usewaypoint/editor-sample
|
||||
|
||||
Use this as a sample to self-host EmailBuilder.js.
|
||||
|
||||
To run this locally, fork the repository and then in this directory run:
|
||||
|
||||
- `npm install`
|
||||
- `npx vite`
|
||||
|
||||
Once the server is running, open http://localhost:5173/email-builder-js/ in your browser.
|
||||
@@ -0,0 +1,87 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/src/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/src/favicon/favicon-16x16.png" />
|
||||
<meta name="viewport" content="width=900" />
|
||||
<meta name="description" content="EmailBuilder.js interactive playground. Brought to you by Waypoint." />
|
||||
<title>EmailBuilder.js — Free and Open Source Template Builder</title>
|
||||
<style>
|
||||
html {
|
||||
margin: 0px;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
#root {
|
||||
/* height: 100vh; */
|
||||
width: 800px;
|
||||
position: relative;
|
||||
}
|
||||
.root-wrapper {
|
||||
padding: 100px;
|
||||
background-color: black;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="root-wrapper">
|
||||
<div id="root" class="email-builder-container"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
const testData = {
|
||||
"root": {
|
||||
"type": "EmailLayout",
|
||||
"data": {
|
||||
"backdropColor": "#F5F5F5",
|
||||
"canvasColor": "#FFFFFF",
|
||||
"textColor": "#262626",
|
||||
"fontFamily": "MODERN_SANS",
|
||||
"childrenIds": [
|
||||
"block-1727858083795"
|
||||
]
|
||||
}
|
||||
},
|
||||
"block-1727858083795": {
|
||||
"type": "Text",
|
||||
"data": {
|
||||
"style": {
|
||||
"fontWeight": "normal",
|
||||
"padding": {
|
||||
"top": 16,
|
||||
"bottom": 16,
|
||||
"right": 24,
|
||||
"left": 24
|
||||
}
|
||||
},
|
||||
"props": {
|
||||
"markdown": false,
|
||||
"text": "Test template"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import('/src/main.tsx')
|
||||
.then(module => {
|
||||
module.render('root', { data: testData, onChange: (json, html) => {
|
||||
console.log("onChange", json, html)
|
||||
}});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading the module:', error);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Prod build -->
|
||||
<!-- <script src="dist/eaglecast-email-builder.umd.js"></script>
|
||||
<script>
|
||||
EmailBuilder.render("root");
|
||||
</script> -->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@eaglecast/email-builder",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.3",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.15.10",
|
||||
"@mui/material": "^5.15.10",
|
||||
"@usewaypoint/block-avatar": "^0.0.3",
|
||||
"@usewaypoint/block-button": "^0.0.3",
|
||||
"@usewaypoint/block-columns-container": "^0.0.3",
|
||||
"@usewaypoint/block-container": "^0.0.2",
|
||||
"@usewaypoint/block-divider": "^0.0.4",
|
||||
"@usewaypoint/block-heading": "^0.0.3",
|
||||
"@usewaypoint/block-html": "^0.0.3",
|
||||
"@usewaypoint/block-image": "^0.0.5",
|
||||
"@usewaypoint/block-spacer": "^0.0.3",
|
||||
"@usewaypoint/block-text": "^0.0.6",
|
||||
"@usewaypoint/document-core": "^0.0.6",
|
||||
"@usewaypoint/email-builder": "^0.0.8",
|
||||
"highlight.js": "^11.9.0",
|
||||
"prettier": "^3.2.5",
|
||||
"react": "^18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-simple-import-sort": "^12.0.0",
|
||||
"terser": "^5.34.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^6.4.2"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Typography } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../documents/editor/core';
|
||||
import { setDocument, useDocument, useSelectedBlockId } from '../../../documents/editor/EditorContext';
|
||||
|
||||
import AvatarSidebarPanel from './input-panels/AvatarSidebarPanel';
|
||||
import ButtonSidebarPanel from './input-panels/ButtonSidebarPanel';
|
||||
import ColumnsContainerSidebarPanel from './input-panels/ColumnsContainerSidebarPanel';
|
||||
import ContainerSidebarPanel from './input-panels/ContainerSidebarPanel';
|
||||
import DividerSidebarPanel from './input-panels/DividerSidebarPanel';
|
||||
import EmailLayoutSidebarPanel from './input-panels/EmailLayoutSidebarPanel';
|
||||
import HeadingSidebarPanel from './input-panels/HeadingSidebarPanel';
|
||||
import HtmlSidebarPanel from './input-panels/HtmlSidebarPanel';
|
||||
import ImageSidebarPanel from './input-panels/ImageSidebarPanel';
|
||||
import SpacerSidebarPanel from './input-panels/SpacerSidebarPanel';
|
||||
import TextSidebarPanel from './input-panels/TextSidebarPanel';
|
||||
|
||||
function renderMessage(val: string) {
|
||||
return (
|
||||
<Box sx={{ m: 3, p: 1, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography color="text.secondary">{val}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigurationPanel() {
|
||||
const document = useDocument();
|
||||
const selectedBlockId = useSelectedBlockId();
|
||||
|
||||
if (!selectedBlockId) {
|
||||
return renderMessage('Click on a block to inspect.');
|
||||
}
|
||||
const block = document[selectedBlockId];
|
||||
if (!block) {
|
||||
return renderMessage(`Block with id ${selectedBlockId} was not found. Click on a block to reset.`);
|
||||
}
|
||||
|
||||
const setBlock = (conf: TEditorBlock) => setDocument({ [selectedBlockId]: conf });
|
||||
const { data, type } = block;
|
||||
switch (type) {
|
||||
case 'Avatar':
|
||||
return <AvatarSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Button':
|
||||
return <ButtonSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'ColumnsContainer':
|
||||
return (
|
||||
<ColumnsContainerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />
|
||||
);
|
||||
case 'Container':
|
||||
return <ContainerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Divider':
|
||||
return <DividerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Heading':
|
||||
return <HeadingSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Html':
|
||||
return <HtmlSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Image':
|
||||
return <ImageSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'EmailLayout':
|
||||
return <EmailLayoutSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Spacer':
|
||||
return <SpacerSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
case 'Text':
|
||||
return <TextSidebarPanel key={selectedBlockId} data={data} setData={(data) => setBlock({ type, data })} />;
|
||||
default:
|
||||
return <pre>{JSON.stringify(block, null, ' ')}</pre>;
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AspectRatioOutlined } from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
import { AvatarProps, AvatarPropsDefaults, AvatarPropsSchema } from '@usewaypoint/block-avatar';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type AvatarSidebarPanelProps = {
|
||||
data: AvatarProps;
|
||||
setData: (v: AvatarProps) => void;
|
||||
};
|
||||
export default function AvatarSidebarPanel({ data, setData }: AvatarSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = AvatarPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const size = data.props?.size ?? AvatarPropsDefaults.size;
|
||||
const imageUrl = data.props?.imageUrl ?? AvatarPropsDefaults.imageUrl;
|
||||
const alt = data.props?.alt ?? AvatarPropsDefaults.alt;
|
||||
const shape = data.props?.shape ?? AvatarPropsDefaults.shape;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Avatar block">
|
||||
<SliderInput
|
||||
label="Size"
|
||||
iconLabel={<AspectRatioOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={3}
|
||||
min={32}
|
||||
max={256}
|
||||
defaultValue={size}
|
||||
onChange={(size) => {
|
||||
updateData({ ...data, props: { ...data.props, size } });
|
||||
}}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Shape"
|
||||
defaultValue={shape}
|
||||
onChange={(shape) => {
|
||||
updateData({ ...data, props: { ...data.props, shape } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="circle">Circle</ToggleButton>
|
||||
<ToggleButton value="square">Square</ToggleButton>
|
||||
<ToggleButton value="rounded">Rounded</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<TextInput
|
||||
label="Image URL"
|
||||
className="image-url"
|
||||
defaultValue={imageUrl}
|
||||
onChange={(imageUrl) => {
|
||||
updateData({ ...data, props: { ...data.props, imageUrl } });
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Alt text"
|
||||
defaultValue={alt}
|
||||
onChange={(alt) => {
|
||||
updateData({ ...data, props: { ...data.props, alt } });
|
||||
}}
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
import { ButtonProps, ButtonPropsDefaults, ButtonPropsSchema } from '@usewaypoint/block-button';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import ColorInput from './helpers/inputs/ColorInput';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ButtonSidebarPanelProps = {
|
||||
data: ButtonProps;
|
||||
setData: (v: ButtonProps) => void;
|
||||
};
|
||||
export default function ButtonSidebarPanel({ data, setData }: ButtonSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ButtonPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const text = data.props?.text ?? ButtonPropsDefaults.text;
|
||||
const url = data.props?.url ?? ButtonPropsDefaults.url;
|
||||
const fullWidth = data.props?.fullWidth ?? ButtonPropsDefaults.fullWidth;
|
||||
const size = data.props?.size ?? ButtonPropsDefaults.size;
|
||||
const buttonStyle = data.props?.buttonStyle ?? ButtonPropsDefaults.buttonStyle;
|
||||
const buttonTextColor = data.props?.buttonTextColor ?? ButtonPropsDefaults.buttonTextColor;
|
||||
const buttonBackgroundColor = data.props?.buttonBackgroundColor ?? ButtonPropsDefaults.buttonBackgroundColor;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Button block">
|
||||
<TextInput
|
||||
label="Text"
|
||||
defaultValue={text}
|
||||
onChange={(text) => updateData({ ...data, props: { ...data.props, text } })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Url"
|
||||
defaultValue={url}
|
||||
onChange={(url) => updateData({ ...data, props: { ...data.props, url } })}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Width"
|
||||
defaultValue={fullWidth ? 'FULL_WIDTH' : 'AUTO'}
|
||||
onChange={(v) => updateData({ ...data, props: { ...data.props, fullWidth: v === 'FULL_WIDTH' } })}
|
||||
>
|
||||
<ToggleButton value="FULL_WIDTH">Full</ToggleButton>
|
||||
<ToggleButton value="AUTO">Auto</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<RadioGroupInput
|
||||
label="Size"
|
||||
defaultValue={size}
|
||||
onChange={(size) => updateData({ ...data, props: { ...data.props, size } })}
|
||||
>
|
||||
<ToggleButton value="x-small">Xs</ToggleButton>
|
||||
<ToggleButton value="small">Sm</ToggleButton>
|
||||
<ToggleButton value="medium">Md</ToggleButton>
|
||||
<ToggleButton value="large">Lg</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<RadioGroupInput
|
||||
label="Style"
|
||||
defaultValue={buttonStyle}
|
||||
onChange={(buttonStyle) => updateData({ ...data, props: { ...data.props, buttonStyle } })}
|
||||
>
|
||||
<ToggleButton value="rectangle">Rectangle</ToggleButton>
|
||||
<ToggleButton value="rounded">Rounded</ToggleButton>
|
||||
<ToggleButton value="pill">Pill</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
defaultValue={buttonTextColor}
|
||||
onChange={(buttonTextColor) => updateData({ ...data, props: { ...data.props, buttonTextColor } })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Button color"
|
||||
defaultValue={buttonBackgroundColor}
|
||||
onChange={(buttonBackgroundColor) => updateData({ ...data, props: { ...data.props, buttonBackgroundColor } })}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'fontFamily', 'fontSize', 'fontWeight', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
SpaceBarOutlined,
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignCenterOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import ColumnsContainerPropsSchema, {
|
||||
ColumnsContainerProps,
|
||||
} from '../../../../documents/blocks/ColumnsContainer/ColumnsContainerPropsSchema';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import ColumnWidthsInput from './helpers/inputs/ColumnWidthsInput';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ColumnsContainerPanelProps = {
|
||||
data: ColumnsContainerProps;
|
||||
setData: (v: ColumnsContainerProps) => void;
|
||||
};
|
||||
export default function ColumnsContainerPanel({ data, setData }: ColumnsContainerPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ColumnsContainerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Columns block">
|
||||
<RadioGroupInput
|
||||
label="Number of columns"
|
||||
defaultValue={data.props?.columnsCount === 2 ? '2' : '3'}
|
||||
onChange={(v) => {
|
||||
updateData({ ...data, props: { ...data.props, columnsCount: v === '2' ? 2 : 3 } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="2">2</ToggleButton>
|
||||
<ToggleButton value="3">3</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<ColumnWidthsInput
|
||||
defaultValue={data.props?.fixedWidths}
|
||||
onChange={(fixedWidths) => {
|
||||
updateData({ ...data, props: { ...data.props, fixedWidths } });
|
||||
}}
|
||||
/>
|
||||
<SliderInput
|
||||
label="Columns gap"
|
||||
iconLabel={<SpaceBarOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={80}
|
||||
defaultValue={data.props?.columnsGap ?? 0}
|
||||
onChange={(columnsGap) => updateData({ ...data, props: { ...data.props, columnsGap } })}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Alignment"
|
||||
defaultValue={data.props?.contentAlignment ?? 'middle'}
|
||||
onChange={(contentAlignment) => {
|
||||
updateData({ ...data, props: { ...data.props, contentAlignment } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="top">
|
||||
<VerticalAlignTopOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle">
|
||||
<VerticalAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="bottom">
|
||||
<VerticalAlignBottomOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import ContainerPropsSchema, { ContainerProps } from '../../../../documents/blocks/Container/ContainerPropsSchema';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ContainerSidebarPanelProps = {
|
||||
data: ContainerProps;
|
||||
setData: (v: ContainerProps) => void;
|
||||
};
|
||||
|
||||
export default function ContainerSidebarPanel({ data, setData }: ContainerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ContainerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<BaseSidebarPanel title="Container block">
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'borderColor', 'borderRadius', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HeightOutlined } from '@mui/icons-material';
|
||||
import { DividerProps, DividerPropsDefaults, DividerPropsSchema } from '@usewaypoint/block-divider';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import ColorInput from './helpers/inputs/ColorInput';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type DividerSidebarPanelProps = {
|
||||
data: DividerProps;
|
||||
setData: (v: DividerProps) => void;
|
||||
};
|
||||
export default function DividerSidebarPanel({ data, setData }: DividerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
const updateData = (d: unknown) => {
|
||||
const res = DividerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const lineColor = data.props?.lineColor ?? DividerPropsDefaults.lineColor;
|
||||
const lineHeight = data.props?.lineHeight ?? DividerPropsDefaults.lineHeight;
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Divider block">
|
||||
<ColorInput
|
||||
label="Color"
|
||||
defaultValue={lineColor}
|
||||
onChange={(lineColor) => updateData({ ...data, props: { ...data.props, lineColor } })}
|
||||
/>
|
||||
<SliderInput
|
||||
label="Height"
|
||||
iconLabel={<HeightOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={1}
|
||||
min={1}
|
||||
max={24}
|
||||
defaultValue={lineHeight}
|
||||
onChange={(lineHeight) => updateData({ ...data, props: { ...data.props, lineHeight } })}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { RoundedCornerOutlined } from '@mui/icons-material';
|
||||
|
||||
import EmailLayoutPropsSchema, {
|
||||
EmailLayoutProps,
|
||||
} from '../../../../documents/blocks/EmailLayout/EmailLayoutPropsSchema';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import BooleanInput from './helpers/inputs/BooleanInput';
|
||||
import ColorInput, { NullableColorInput } from './helpers/inputs/ColorInput';
|
||||
import { NullableFontFamily } from './helpers/inputs/FontFamily';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
|
||||
type EmailLayoutSidebarFieldsProps = {
|
||||
data: EmailLayoutProps;
|
||||
setData: (v: EmailLayoutProps) => void;
|
||||
};
|
||||
export default function EmailLayoutSidebarFields({ data, setData }: EmailLayoutSidebarFieldsProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = EmailLayoutPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Global">
|
||||
<ColorInput
|
||||
label="Backdrop color"
|
||||
defaultValue={data.backdropColor ?? '#F5F5F5'}
|
||||
onChange={(backdropColor) => updateData({ ...data, backdropColor })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Canvas color"
|
||||
defaultValue={data.canvasColor ?? '#FFFFFF'}
|
||||
onChange={(canvasColor) => updateData({ ...data, canvasColor })}
|
||||
/>
|
||||
<NullableColorInput
|
||||
label="Canvas border color"
|
||||
defaultValue={data.borderColor ?? null}
|
||||
onChange={(borderColor) => updateData({ ...data, borderColor })}
|
||||
/>
|
||||
<SliderInput
|
||||
iconLabel={<RoundedCornerOutlined />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={48}
|
||||
label="Canvas border radius"
|
||||
defaultValue={data.borderRadius ?? 0}
|
||||
onChange={(borderRadius) => updateData({ ...data, borderRadius })}
|
||||
/>
|
||||
<NullableFontFamily
|
||||
label="Font family"
|
||||
defaultValue="MODERN_SANS"
|
||||
onChange={(fontFamily) => updateData({ ...data, fontFamily })}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
defaultValue={data.textColor ?? '#262626'}
|
||||
onChange={(textColor) => updateData({ ...data, textColor })}
|
||||
/>
|
||||
<BooleanInput
|
||||
label="Outlook compatibility"
|
||||
defaultValue={data.outlook ?? false}
|
||||
onChange={(outlook) => updateData({ ...data, outlook })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
import { HeadingProps, HeadingPropsDefaults, HeadingPropsSchema } from '@usewaypoint/block-heading';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type HeadingSidebarPanelProps = {
|
||||
data: HeadingProps;
|
||||
setData: (v: HeadingProps) => void;
|
||||
};
|
||||
export default function HeadingSidebarPanel({ data, setData }: HeadingSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = HeadingPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Heading block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={3}
|
||||
defaultValue={data.props?.text ?? HeadingPropsDefaults.text}
|
||||
onChange={(text) => {
|
||||
updateData({ ...data, props: { ...data.props, text } });
|
||||
}}
|
||||
/>
|
||||
<RadioGroupInput
|
||||
label="Level"
|
||||
defaultValue={data.props?.level ?? HeadingPropsDefaults.level}
|
||||
onChange={(level) => {
|
||||
updateData({ ...data, props: { ...data.props, level } });
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="h1">H1</ToggleButton>
|
||||
<ToggleButton value="h2">H2</ToggleButton>
|
||||
<ToggleButton value="h3">H3</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
<MultiStylePropertyPanel
|
||||
names={['color', 'backgroundColor', 'fontFamily', 'fontWeight', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HtmlProps, HtmlPropsSchema } from '@usewaypoint/block-html';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type HtmlSidebarPanelProps = {
|
||||
data: HtmlProps;
|
||||
setData: (v: HtmlProps) => void;
|
||||
};
|
||||
export default function HtmlSidebarPanel({ data, setData }: HtmlSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = HtmlPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Html block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={5}
|
||||
defaultValue={data.props?.contents ?? ''}
|
||||
onChange={(contents) => updateData({ ...data, props: { ...data.props, contents } })}
|
||||
/>
|
||||
<MultiStylePropertyPanel
|
||||
names={['color', 'backgroundColor', 'fontFamily', 'fontSize', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
VerticalAlignBottomOutlined,
|
||||
VerticalAlignCenterOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import { Checkbox, FormControlLabel, Stack, ToggleButton } from '@mui/material';
|
||||
import { ImageProps } from '@usewaypoint/block-image';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ImgPropsSchema, EagleCastImageProps } from '../../../../documents/editor/core';
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import RadioGroupInput from './helpers/inputs/RadioGroupInput';
|
||||
import TextDimensionInput from './helpers/inputs/TextDimensionInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type ImageSidebarPanelProps = {
|
||||
data: ImageProps;
|
||||
setData: (v: ImageProps) => void;
|
||||
};
|
||||
export default function ImageSidebarPanel({ data, setData }: ImageSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = ImgPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data as ImageProps);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const props = (data && (data as EagleCastImageProps).props) || {};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Image block">
|
||||
<TextInput
|
||||
label="Source URL"
|
||||
className="image-url"
|
||||
defaultValue={data.props?.url ?? ''}
|
||||
onChange={(v) => {
|
||||
const url = v.trim().length === 0 ? null : v.trim();
|
||||
updateData({ ...data, props: { ...data.props, url } });
|
||||
}}
|
||||
/>
|
||||
<a href="#" class="select-media"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', marginTop: '5px' }}
|
||||
onClick={(e) => {
|
||||
// @ts-ignore
|
||||
window.parent.postMessage('visualeditor.select-media', '*');
|
||||
e.preventDefault();
|
||||
}}><CloudUploadIcon style={{fontSize: '1rem'}} /> Select media</a>
|
||||
|
||||
<TextInput
|
||||
label="Alt text"
|
||||
defaultValue={data.props?.alt ?? ''}
|
||||
onChange={(alt) => updateData({ ...data, props: { ...data.props, alt } })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Click through URL"
|
||||
defaultValue={data.props?.linkHref ?? ''}
|
||||
onChange={(v) => {
|
||||
const linkHref = v.trim().length === 0 ? null : v.trim();
|
||||
updateData({ ...data, props: { ...data.props, linkHref } });
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextDimensionInput
|
||||
label="Width"
|
||||
defaultValue={data.props?.width}
|
||||
onChange={(width) => updateData({ ...data, props: { ...data.props, width } })}
|
||||
/>
|
||||
<TextDimensionInput
|
||||
label="Height"
|
||||
defaultValue={data.props?.height}
|
||||
onChange={(height) => updateData({ ...data, props: { ...data.props, height } })}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<RadioGroupInput
|
||||
label="Alignment"
|
||||
defaultValue={data.props?.contentAlignment ?? 'middle'}
|
||||
onChange={(contentAlignment) => updateData({ ...data, props: { ...data.props, contentAlignment } })}
|
||||
>
|
||||
<ToggleButton value="top">
|
||||
<VerticalAlignTopOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle">
|
||||
<VerticalAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="bottom">
|
||||
<VerticalAlignBottomOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={Boolean(props.embed)}
|
||||
onChange={(e) => updateData({ ...data, props: { ...data.props, embed: e.target.checked } })}
|
||||
/>
|
||||
}
|
||||
label="Embed inline (CID)"
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['backgroundColor', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { HeightOutlined } from '@mui/icons-material';
|
||||
import { SpacerProps, SpacerPropsDefaults, SpacerPropsSchema } from '@usewaypoint/block-spacer';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import SliderInput from './helpers/inputs/SliderInput';
|
||||
|
||||
type SpacerSidebarPanelProps = {
|
||||
data: SpacerProps;
|
||||
setData: (v: SpacerProps) => void;
|
||||
};
|
||||
export default function SpacerSidebarPanel({ data, setData }: SpacerSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = SpacerPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Spacer block">
|
||||
<SliderInput
|
||||
label="Height"
|
||||
iconLabel={<HeightOutlined sx={{ color: 'text.secondary' }} />}
|
||||
units="px"
|
||||
step={4}
|
||||
min={4}
|
||||
max={128}
|
||||
defaultValue={data.props?.height ?? SpacerPropsDefaults.height}
|
||||
onChange={(height) => updateData({ ...data, props: { ...data.props, height } })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TextProps, TextPropsSchema } from '@usewaypoint/block-text';
|
||||
|
||||
import BaseSidebarPanel from './helpers/BaseSidebarPanel';
|
||||
import BooleanInput from './helpers/inputs/BooleanInput';
|
||||
import TextInput from './helpers/inputs/TextInput';
|
||||
import MultiStylePropertyPanel from './helpers/style-inputs/MultiStylePropertyPanel';
|
||||
|
||||
type TextSidebarPanelProps = {
|
||||
data: TextProps;
|
||||
setData: (v: TextProps) => void;
|
||||
};
|
||||
export default function TextSidebarPanel({ data, setData }: TextSidebarPanelProps) {
|
||||
const [, setErrors] = useState<Zod.ZodError | null>(null);
|
||||
|
||||
const updateData = (d: unknown) => {
|
||||
const res = TextPropsSchema.safeParse(d);
|
||||
if (res.success) {
|
||||
setData(res.data);
|
||||
setErrors(null);
|
||||
} else {
|
||||
setErrors(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseSidebarPanel title="Text block">
|
||||
<TextInput
|
||||
label="Content"
|
||||
rows={5}
|
||||
defaultValue={data.props?.text ?? ''}
|
||||
onChange={(text) => updateData({ ...data, props: { ...data.props, text } })}
|
||||
/>
|
||||
<BooleanInput
|
||||
label="Markdown"
|
||||
defaultValue={data.props?.markdown ?? false}
|
||||
onChange={(markdown) => updateData({ ...data, props: { ...data.props, markdown } })}
|
||||
/>
|
||||
|
||||
<MultiStylePropertyPanel
|
||||
names={['color', 'backgroundColor', 'fontFamily', 'fontSize', 'fontWeight', 'textAlign', 'padding']}
|
||||
value={data.style}
|
||||
onChange={(style) => updateData({ ...data, style })}
|
||||
/>
|
||||
</BaseSidebarPanel>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
|
||||
type SidebarPanelProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export default function BaseSidebarPanel({ title, children }: SidebarPanelProps) {
|
||||
return (
|
||||
<Box p={2}>
|
||||
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', mb: 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Stack spacing={5} mb={3}>
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FormControlLabel, Switch } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export default function BooleanInput({ label, defaultValue, onChange }: Props) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
label={label}
|
||||
control={
|
||||
<Switch
|
||||
checked={defaultValue}
|
||||
onChange={(_, checked: boolean) => {
|
||||
onChange(checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { AddOutlined, CloseOutlined } from '@mui/icons-material';
|
||||
import { ButtonBase, InputLabel, Menu, Stack } from '@mui/material';
|
||||
|
||||
import Picker from './Picker';
|
||||
|
||||
const BUTTON_SX = {
|
||||
border: '1px solid',
|
||||
borderColor: 'cadet.400',
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '4px',
|
||||
bgcolor: '#FFFFFF',
|
||||
};
|
||||
|
||||
type Props =
|
||||
| {
|
||||
nullable: true;
|
||||
label: string;
|
||||
onChange: (value: string | null) => void;
|
||||
defaultValue: string | null;
|
||||
}
|
||||
| {
|
||||
nullable: false;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue: string;
|
||||
};
|
||||
export default function ColorInput({ label, defaultValue, onChange, nullable }: Props) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const handleClickOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const renderResetButton = () => {
|
||||
if (!nullable) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={() => {
|
||||
setValue(null);
|
||||
onChange(null);
|
||||
}}
|
||||
>
|
||||
<CloseOutlined fontSize="small" sx={{ color: 'grey.600' }} />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOpenButton = () => {
|
||||
if (value) {
|
||||
return <ButtonBase onClick={handleClickOpen} sx={{ ...BUTTON_SX, bgcolor: value }} />;
|
||||
}
|
||||
return (
|
||||
<ButtonBase onClick={handleClickOpen} sx={{ ...BUTTON_SX }}>
|
||||
<AddOutlined fontSize="small" />
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack alignItems="flex-start">
|
||||
<InputLabel sx={{ mb: 0.5 }}>{label}</InputLabel>
|
||||
<Stack direction="row" spacing={1}>
|
||||
{renderOpenButton()}
|
||||
{renderResetButton()}
|
||||
</Stack>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={() => setAnchorEl(null)}
|
||||
MenuListProps={{
|
||||
sx: { height: 'auto', padding: 0 },
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
value={value || ''}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
</Menu>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { HexColorInput, HexColorPicker } from 'react-colorful';
|
||||
|
||||
import { Box, Stack, SxProps } from '@mui/material';
|
||||
|
||||
import Swatch from './Swatch';
|
||||
|
||||
const DEFAULT_PRESET_COLORS = [
|
||||
'#E11D48',
|
||||
'#DB2777',
|
||||
'#C026D3',
|
||||
'#9333EA',
|
||||
'#7C3AED',
|
||||
'#4F46E5',
|
||||
'#2563EB',
|
||||
'#0284C7',
|
||||
'#0891B2',
|
||||
'#0D9488',
|
||||
'#059669',
|
||||
'#16A34A',
|
||||
'#65A30D',
|
||||
'#CA8A04',
|
||||
'#D97706',
|
||||
'#EA580C',
|
||||
'#DC2626',
|
||||
'#FFFFFF',
|
||||
'#FAFAFA',
|
||||
'#F5F5F5',
|
||||
'#E5E5E5',
|
||||
'#D4D4D4',
|
||||
'#A3A3A3',
|
||||
'#737373',
|
||||
'#525252',
|
||||
'#404040',
|
||||
'#262626',
|
||||
'#171717',
|
||||
'#0A0A0A',
|
||||
'#000000',
|
||||
];
|
||||
|
||||
const SX: SxProps = {
|
||||
p: 1,
|
||||
'.react-colorful__pointer ': {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'.react-colorful__saturation': {
|
||||
mb: 1,
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'.react-colorful__last-control': {
|
||||
borderRadius: '4px',
|
||||
},
|
||||
'.react-colorful__hue-pointer': {
|
||||
width: '4px',
|
||||
borderRadius: '4px',
|
||||
height: 24,
|
||||
cursor: 'col-resize',
|
||||
},
|
||||
'.react-colorful__saturation-pointer': {
|
||||
cursor: 'all-scroll',
|
||||
},
|
||||
input: {
|
||||
padding: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.300',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function Picker({ value, onChange }: Props) {
|
||||
return (
|
||||
<Stack spacing={1} sx={SX}>
|
||||
<HexColorPicker color={value} onChange={onChange} />
|
||||
<Swatch paletteColors={DEFAULT_PRESET_COLORS} value={value} onChange={onChange} />
|
||||
<Box pt={1}>
|
||||
<HexColorInput prefixed color={value} onChange={onChange} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Button, SxProps } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
paletteColors: string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const TILE_BUTTON: SxProps = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
};
|
||||
export default function Swatch({ paletteColors, value, onChange }: Props) {
|
||||
const renderButton = (colorValue: string) => {
|
||||
return (
|
||||
<Button
|
||||
key={colorValue}
|
||||
onClick={() => onChange(colorValue)}
|
||||
sx={{
|
||||
...TILE_BUTTON,
|
||||
backgroundColor: colorValue,
|
||||
border: '1px solid',
|
||||
borderColor: value === colorValue ? 'black' : 'grey.200',
|
||||
minWidth: 24,
|
||||
display: 'inline-flex',
|
||||
'&:hover': {
|
||||
backgroundColor: colorValue,
|
||||
borderColor: 'grey.500',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Box width="100%" sx={{ display: 'grid', gap: 1, gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr 1fr' }}>
|
||||
{paletteColors.map((c) => renderButton(c))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
import BaseColorInput from './BaseColorInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultValue: string;
|
||||
};
|
||||
export default function ColorInput(props: Props) {
|
||||
return <BaseColorInput {...props} nullable={false} />;
|
||||
}
|
||||
|
||||
type NullableProps = {
|
||||
label: string;
|
||||
onChange: (value: null | string) => void;
|
||||
defaultValue: null | string;
|
||||
};
|
||||
export function NullableColorInput(props: NullableProps) {
|
||||
return <BaseColorInput {...props} nullable />;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Stack } from '@mui/material';
|
||||
|
||||
import TextDimensionInput from './TextDimensionInput';
|
||||
|
||||
export const DEFAULT_2_COLUMNS = [6] as [number];
|
||||
export const DEFAULT_3_COLUMNS = [4, 8] as [number, number];
|
||||
|
||||
type TWidthValue = number | null | undefined;
|
||||
type FixedWidths = [
|
||||
//
|
||||
number | null | undefined,
|
||||
number | null | undefined,
|
||||
number | null | undefined,
|
||||
];
|
||||
type ColumnsLayoutInputProps = {
|
||||
defaultValue: FixedWidths | null | undefined;
|
||||
onChange: (v: FixedWidths | null | undefined) => void;
|
||||
};
|
||||
export default function ColumnWidthsInput({ defaultValue, onChange }: ColumnsLayoutInputProps) {
|
||||
const [currentValue, setCurrentValue] = useState<[TWidthValue, TWidthValue, TWidthValue]>(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return [null, null, null];
|
||||
});
|
||||
|
||||
const setIndexValue = (index: 0 | 1 | 2, value: number | null | undefined) => {
|
||||
const nValue: FixedWidths = [...currentValue];
|
||||
nValue[index] = value;
|
||||
setCurrentValue(nValue);
|
||||
onChange(nValue);
|
||||
};
|
||||
|
||||
const columnsCountValue = 3;
|
||||
let column3 = null;
|
||||
if (columnsCountValue === 3) {
|
||||
column3 = (
|
||||
<TextDimensionInput
|
||||
label="Column 3"
|
||||
defaultValue={currentValue?.[2]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(2, v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Stack direction="row" spacing={1}>
|
||||
<TextDimensionInput
|
||||
label="Column 1"
|
||||
defaultValue={currentValue?.[0]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(0, v);
|
||||
}}
|
||||
/>
|
||||
<TextDimensionInput
|
||||
label="Column 2"
|
||||
defaultValue={currentValue?.[1]}
|
||||
onChange={(v) => {
|
||||
setIndexValue(1, v);
|
||||
}}
|
||||
/>
|
||||
{column3}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { MenuItem, TextField } from '@mui/material';
|
||||
|
||||
import { FONT_FAMILIES } from '../../../../../../documents/blocks/helpers/fontFamily';
|
||||
|
||||
const OPTIONS = FONT_FAMILIES.map((option) => (
|
||||
<MenuItem key={option.key} value={option.key} sx={{ fontFamily: option.value }}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
));
|
||||
|
||||
type NullableProps = {
|
||||
label: string;
|
||||
onChange: (value: null | string) => void;
|
||||
defaultValue: null | string;
|
||||
};
|
||||
export function NullableFontFamily({ label, onChange, defaultValue }: NullableProps) {
|
||||
const [value, setValue] = useState(defaultValue ?? 'inherit');
|
||||
return (
|
||||
<TextField
|
||||
select
|
||||
variant="standard"
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value;
|
||||
setValue(v);
|
||||
onChange(v === null ? null : v);
|
||||
}}
|
||||
>
|
||||
<MenuItem value="inherit">Match email settings</MenuItem>
|
||||
{OPTIONS}
|
||||
</TextField>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TextFieldsOutlined } from '@mui/icons-material';
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: number;
|
||||
onChange: (v: number) => void;
|
||||
};
|
||||
export default function FontSizeInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const handleChange = (value: number) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
};
|
||||
return (
|
||||
<Stack spacing={1} alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<RawSliderInput
|
||||
iconLabel={<TextFieldsOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value}
|
||||
setValue={handleChange}
|
||||
units="px"
|
||||
step={1}
|
||||
min={10}
|
||||
max={48}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import RadioGroupInput from './RadioGroupInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
export default function FontWeightInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<RadioGroupInput
|
||||
label={label}
|
||||
defaultValue={value}
|
||||
onChange={(fontWeight) => {
|
||||
setValue(fontWeight);
|
||||
onChange(fontWeight);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="normal">Regular</ToggleButton>
|
||||
<ToggleButton value="bold">Bold</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
);
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
AlignHorizontalLeftOutlined,
|
||||
AlignHorizontalRightOutlined,
|
||||
AlignVerticalBottomOutlined,
|
||||
AlignVerticalTopOutlined,
|
||||
} from '@mui/icons-material';
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type TPaddingValue = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
right: number;
|
||||
left: number;
|
||||
};
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: TPaddingValue | null;
|
||||
onChange: (value: TPaddingValue) => void;
|
||||
};
|
||||
export default function PaddingInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(() => {
|
||||
if (defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
return {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
};
|
||||
});
|
||||
|
||||
function handleChange(internalName: keyof TPaddingValue, nValue: number) {
|
||||
const v = {
|
||||
...value,
|
||||
[internalName]: nValue,
|
||||
};
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2} alignItems="flex-start" pb={1}>
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignVerticalTopOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.top}
|
||||
setValue={(num) => handleChange('top', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignVerticalBottomOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.bottom}
|
||||
setValue={(num) => handleChange('bottom', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignHorizontalLeftOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.left}
|
||||
setValue={(num) => handleChange('left', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
|
||||
<RawSliderInput
|
||||
iconLabel={<AlignHorizontalRightOutlined sx={{ fontSize: 16 }} />}
|
||||
value={value.right}
|
||||
setValue={(num) => handleChange('right', num)}
|
||||
units="px"
|
||||
step={4}
|
||||
min={0}
|
||||
max={80}
|
||||
marks
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputLabel, Stack, ToggleButtonGroup } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string | JSX.Element;
|
||||
children: JSX.Element | JSX.Element[];
|
||||
defaultValue: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function RadioGroupInput({ label, children, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<Stack alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
fullWidth
|
||||
value={value}
|
||||
size="small"
|
||||
onChange={(_, v: unknown) => {
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error('RadioGroupInput can only receive string values');
|
||||
}
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputLabel, Stack } from '@mui/material';
|
||||
|
||||
import RawSliderInput from './raw/RawSliderInput';
|
||||
|
||||
type SliderInputProps = {
|
||||
label: string;
|
||||
iconLabel: JSX.Element;
|
||||
|
||||
step?: number;
|
||||
marks?: boolean;
|
||||
units: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
defaultValue: number;
|
||||
onChange: (v: number) => void;
|
||||
};
|
||||
|
||||
export default function SliderInput({ label, defaultValue, onChange, ...props }: SliderInputProps) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
return (
|
||||
<Stack spacing={1} alignItems="flex-start">
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<RawSliderInput
|
||||
value={value}
|
||||
setValue={(value: number) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FormatAlignCenterOutlined, FormatAlignLeftOutlined, FormatAlignRightOutlined } from '@mui/icons-material';
|
||||
import { ToggleButton } from '@mui/material';
|
||||
|
||||
import RadioGroupInput from './RadioGroupInput';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
defaultValue: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
};
|
||||
export default function TextAlignInput({ label, defaultValue, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue ?? 'left');
|
||||
|
||||
return (
|
||||
<RadioGroupInput
|
||||
label={label}
|
||||
defaultValue={value}
|
||||
onChange={(value) => {
|
||||
setValue(value);
|
||||
onChange(value);
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="left">
|
||||
<FormatAlignLeftOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="center">
|
||||
<FormatAlignCenterOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
<ToggleButton value="right">
|
||||
<FormatAlignRightOutlined fontSize="small" />
|
||||
</ToggleButton>
|
||||
</RadioGroupInput>
|
||||
);
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TextField, Typography } from '@mui/material';
|
||||
|
||||
type TextDimensionInputProps = {
|
||||
label: string;
|
||||
defaultValue: number | null | undefined;
|
||||
onChange: (v: number | null) => void;
|
||||
};
|
||||
export default function TextDimensionInput({ label, defaultValue, onChange }: TextDimensionInputProps) {
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (ev) => {
|
||||
const value = parseInt(ev.target.value);
|
||||
onChange(isNaN(value) ? null : value);
|
||||
};
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
onChange={handleChange}
|
||||
defaultValue={defaultValue}
|
||||
label={label}
|
||||
variant="standard"
|
||||
placeholder="auto"
|
||||
size="small"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
px
|
||||
</Typography>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { InputProps, TextField } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
helperText?: string | JSX.Element;
|
||||
InputProps?: InputProps;
|
||||
defaultValue: string;
|
||||
className?: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
export default function TextInput({ helperText, label, placeholder, rows, InputProps, defaultValue, className, onChange }: Props) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const isMultiline = typeof rows === 'number' && rows > 1;
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline={isMultiline}
|
||||
minRows={rows}
|
||||
variant={isMultiline ? 'outlined' : 'standard'}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
helperText={helperText}
|
||||
InputProps={InputProps}
|
||||
className={className}
|
||||
value={value}
|
||||
onChange={(ev) => {
|
||||
const v = ev.target.value;
|
||||
setValue(v);
|
||||
onChange(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Slider, Stack, Typography } from '@mui/material';
|
||||
|
||||
type SliderInputProps = {
|
||||
iconLabel: JSX.Element;
|
||||
|
||||
step?: number;
|
||||
marks?: boolean;
|
||||
units: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
|
||||
value: number;
|
||||
setValue: (v: number) => void;
|
||||
};
|
||||
|
||||
export default function RawSliderInput({ iconLabel, value, setValue, units, ...props }: SliderInputProps) {
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" spacing={2} justifyContent="space-between" width="100%">
|
||||
<Box sx={{ minWidth: 24, lineHeight: 1, flexShrink: 0 }}>{iconLabel}</Box>
|
||||
<Slider
|
||||
{...props}
|
||||
value={value}
|
||||
onChange={(_, value: unknown) => {
|
||||
if (typeof value !== 'number') {
|
||||
throw new Error('RawSliderInput values can only receive numeric values');
|
||||
}
|
||||
setValue(value);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ minWidth: 32, textAlign: 'right', flexShrink: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ lineHeight: 1 }}>
|
||||
{value}
|
||||
{units}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TStyle } from '../../../../../../documents/blocks/helpers/TStyle';
|
||||
|
||||
import SingleStylePropertyPanel from './SingleStylePropertyPanel';
|
||||
|
||||
type MultiStylePropertyPanelProps = {
|
||||
names: (keyof TStyle)[];
|
||||
value: TStyle | undefined | null;
|
||||
onChange: (style: TStyle) => void;
|
||||
};
|
||||
export default function MultiStylePropertyPanel({ names, value, onChange }: MultiStylePropertyPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{names.map((name) => (
|
||||
<SingleStylePropertyPanel key={name} name={name} value={value || {}} onChange={onChange} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
|
||||
import { RoundedCornerOutlined } from '@mui/icons-material';
|
||||
|
||||
import { TStyle } from '../../../../../../documents/blocks/helpers/TStyle';
|
||||
import { NullableColorInput } from '../inputs/ColorInput';
|
||||
import { NullableFontFamily } from '../inputs/FontFamily';
|
||||
import FontSizeInput from '../inputs/FontSizeInput';
|
||||
import FontWeightInput from '../inputs/FontWeightInput';
|
||||
import PaddingInput from '../inputs/PaddingInput';
|
||||
import SliderInput from '../inputs/SliderInput';
|
||||
import TextAlignInput from '../inputs/TextAlignInput';
|
||||
|
||||
type StylePropertyPanelProps = {
|
||||
name: keyof TStyle;
|
||||
value: TStyle;
|
||||
onChange: (style: TStyle) => void;
|
||||
};
|
||||
export default function SingleStylePropertyPanel({ name, value, onChange }: StylePropertyPanelProps) {
|
||||
const defaultValue = value[name] ?? null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleChange = (v: any) => {
|
||||
onChange({ ...value, [name]: v });
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'backgroundColor':
|
||||
return <NullableColorInput label="Background color" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'borderColor':
|
||||
return <NullableColorInput label="Border color" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'borderRadius':
|
||||
return (
|
||||
<SliderInput
|
||||
iconLabel={<RoundedCornerOutlined />}
|
||||
units="px"
|
||||
step={4}
|
||||
marks
|
||||
min={0}
|
||||
max={48}
|
||||
label="Border radius"
|
||||
defaultValue={defaultValue}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
case 'color':
|
||||
return <NullableColorInput label="Text color" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'fontFamily':
|
||||
return <NullableFontFamily label="Font family" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'fontSize':
|
||||
return <FontSizeInput label="Font size" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'fontWeight':
|
||||
return <FontWeightInput label="Font weight" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'textAlign':
|
||||
return <TextAlignInput label="Alignment" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
case 'padding':
|
||||
return <PaddingInput label="Padding" defaultValue={defaultValue} onChange={handleChange} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
|
||||
import { setDocument, useDocument } from '../../documents/editor/EditorContext';
|
||||
|
||||
import EmailLayoutSidebarPanel from './ConfigurationPanel/input-panels/EmailLayoutSidebarPanel';
|
||||
|
||||
export default function StylesPanel() {
|
||||
const block = useDocument().root;
|
||||
if (!block) {
|
||||
return <p>Block not found</p>;
|
||||
}
|
||||
|
||||
const { data, type } = block;
|
||||
if (type !== 'EmailLayout') {
|
||||
throw new Error('Expected "root" element to be of type EmailLayout');
|
||||
}
|
||||
|
||||
return <EmailLayoutSidebarPanel key="root" data={data} setData={(data) => setDocument({ root: { type, data } })} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AppRegistrationOutlined, LastPageOutlined } from '@mui/icons-material';
|
||||
import { IconButton } from '@mui/material';
|
||||
|
||||
import { toggleInspectorDrawerOpen, useInspectorDrawerOpen } from '../../documents/editor/EditorContext';
|
||||
|
||||
export default function ToggleInspectorPanelButton() {
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
|
||||
const handleClick = () => {
|
||||
toggleInspectorDrawerOpen();
|
||||
};
|
||||
if (inspectorDrawerOpen) {
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<LastPageOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<IconButton onClick={handleClick}>
|
||||
<AppRegistrationOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
Box, Drawer, Tab, Tabs,
|
||||
} from '@mui/material';
|
||||
|
||||
import { setSidebarTab, useInspectorDrawerOpen, useSelectedSidebarTab } from '../../documents/editor/EditorContext';
|
||||
|
||||
import ConfigurationPanel from './ConfigurationPanel';
|
||||
import StylesPanel from './StylesPanel';
|
||||
|
||||
export const INSPECTOR_DRAWER_WIDTH = 320;
|
||||
|
||||
export default function InspectorDrawer() {
|
||||
const selectedSidebarTab = useSelectedSidebarTab();
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
|
||||
const renderCurrentSidebarPanel = () => {
|
||||
switch (selectedSidebarTab) {
|
||||
case 'block-configuration':
|
||||
return <ConfigurationPanel />;
|
||||
case 'styles':
|
||||
return <StylesPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="persistent"
|
||||
anchor="right"
|
||||
className="sidebar"
|
||||
open={inspectorDrawerOpen}
|
||||
sx={{
|
||||
width: inspectorDrawerOpen ? INSPECTOR_DRAWER_WIDTH : 0,
|
||||
}}
|
||||
// Make the drawer relative to the wrapper instead of body.
|
||||
PaperProps={{ style: { position: 'absolute', zIndex: 0 } }}
|
||||
ModalProps={{
|
||||
container: document.querySelector('.email-builder-container'),
|
||||
style: { position: 'absolute', zIndex: 0 },
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
width: INSPECTOR_DRAWER_WIDTH, height: 49, borderBottom: 1, borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box px={2}>
|
||||
<Tabs value={selectedSidebarTab} onChange={(_, v) => setSidebarTab(v)}>
|
||||
<Tab value="styles" label="Styles" />
|
||||
<Tab value="block-configuration" label="Inspect" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ width: INSPECTOR_DRAWER_WIDTH, height: 'calc(100% - 49px)', overflow: 'auto' }}>
|
||||
{renderCurrentSidebarPanel()}
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { FileDownloadOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
|
||||
import { useDocument } from '../../../documents/editor/EditorContext';
|
||||
|
||||
export default function DownloadJson() {
|
||||
const doc = useDocument();
|
||||
const href = useMemo(() => {
|
||||
return `data:text/plain,${encodeURIComponent(JSON.stringify(doc, null, ' '))}`;
|
||||
}, [doc]);
|
||||
return (
|
||||
<Tooltip title="Download JSON file">
|
||||
<IconButton href={href} download="emailTemplate.json">
|
||||
<FileDownloadOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useDocument } from '../../documents/editor/EditorContext';
|
||||
import { renderHtmlWithMeta } from '../../utils';
|
||||
|
||||
import HighlightedCodePanel from './helper/HighlightedCodePanel';
|
||||
|
||||
export default function HtmlPanel() {
|
||||
const document = useDocument();
|
||||
const code = useMemo(
|
||||
() => renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: Boolean(document.root?.data?.outlook) }),
|
||||
[document]
|
||||
);
|
||||
return <HighlightedCodePanel type="html" value={code} />;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Link,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
import { resetDocument } from '../../../documents/editor/EditorContext';
|
||||
|
||||
import validateJsonStringValue from './validateJsonStringValue';
|
||||
|
||||
type ImportJsonDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
export default function ImportJsonDialog({ onClose }: ImportJsonDialogProps) {
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (ev) => {
|
||||
const v = ev.currentTarget.value;
|
||||
setValue(v);
|
||||
const { error } = validateJsonStringValue(v);
|
||||
setError(error ?? null);
|
||||
};
|
||||
|
||||
let errorAlert = null;
|
||||
if (error) {
|
||||
errorAlert = <Alert color="error">{error}</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose}>
|
||||
<DialogTitle>Import JSON</DialogTitle>
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
const { error, data } = validateJsonStringValue(value);
|
||||
setError(error ?? null);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
resetDocument(data);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Typography color="text.secondary" paragraph>
|
||||
Copy and paste an EmailBuilder.js JSON (
|
||||
<Link
|
||||
href="https://gist.githubusercontent.com/jordanisip/efb61f56ba71bd36d3a9440122cb7f50/raw/30ea74a6ac7e52ebdc309bce07b71a9286ce2526/emailBuilderTemplate.json"
|
||||
target="_blank"
|
||||
underline="none"
|
||||
>
|
||||
example
|
||||
</Link>
|
||||
).
|
||||
</Typography>
|
||||
{errorAlert}
|
||||
<TextField
|
||||
error={error !== null}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
type="text"
|
||||
helperText="This will override your current template."
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
rows={10}
|
||||
multiline
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button type="button" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" type="submit" disabled={error !== null}>
|
||||
Import
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { FileUploadOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
|
||||
import ImportJsonDialog from './ImportJsonDialog';
|
||||
|
||||
export default function ImportJson() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
let dialog = null;
|
||||
if (open) {
|
||||
dialog = <ImportJsonDialog onClose={() => setOpen(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Import JSON">
|
||||
<IconButton onClick={() => setOpen(true)}>
|
||||
<FileUploadOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{dialog}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EditorConfigurationSchema, TEditorConfiguration } from '../../../documents/editor/core';
|
||||
|
||||
type TResult = { error: string; data?: undefined } | { data: TEditorConfiguration; error?: undefined };
|
||||
|
||||
export default function validateTextAreaValue(value: string): TResult {
|
||||
let jsonObject = undefined;
|
||||
try {
|
||||
jsonObject = JSON.parse(value);
|
||||
} catch {
|
||||
return { error: 'Invalid json' };
|
||||
}
|
||||
|
||||
const parseResult = EditorConfigurationSchema.safeParse(jsonObject);
|
||||
if (!parseResult.success) {
|
||||
return { error: 'Invalid JSON schema' };
|
||||
}
|
||||
|
||||
if (!parseResult.data.root) {
|
||||
return { error: 'Missing "root" node' };
|
||||
}
|
||||
|
||||
return { data: parseResult.data };
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useDocument } from '../../documents/editor/EditorContext';
|
||||
|
||||
import HighlightedCodePanel from './helper/HighlightedCodePanel';
|
||||
|
||||
export default function JsonPanel() {
|
||||
const document = useDocument();
|
||||
const code = useMemo(() => JSON.stringify(document, null, ' '), [document]);
|
||||
return <HighlightedCodePanel type="json" value={code} />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
|
||||
import { CodeOutlined, DataObjectOutlined, EditOutlined, PreviewOutlined } from '@mui/icons-material';
|
||||
import { Tab, Tabs, Tooltip } from '@mui/material';
|
||||
|
||||
import { setSelectedMainTab, useSelectedMainTab } from '../../documents/editor/EditorContext';
|
||||
|
||||
export default function MainTabsGroup() {
|
||||
const selectedMainTab = useSelectedMainTab();
|
||||
const handleChange = (_: unknown, v: unknown) => {
|
||||
switch (v) {
|
||||
case 'json':
|
||||
case 'preview':
|
||||
case 'editor':
|
||||
case 'html':
|
||||
setSelectedMainTab(v);
|
||||
return;
|
||||
default:
|
||||
setSelectedMainTab('editor');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={selectedMainTab} onChange={handleChange}>
|
||||
<Tab
|
||||
value="editor"
|
||||
label={
|
||||
<Tooltip title="Edit">
|
||||
<EditOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="preview"
|
||||
label={
|
||||
<Tooltip title="Preview">
|
||||
<PreviewOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="html"
|
||||
label={
|
||||
<Tooltip title="HTML output">
|
||||
<CodeOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Tab
|
||||
value="json"
|
||||
label={
|
||||
<Tooltip title="JSON output">
|
||||
<DataObjectOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { IosShareOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Snackbar, Tooltip } from '@mui/material';
|
||||
|
||||
import { useDocument } from '../../documents/editor/EditorContext';
|
||||
|
||||
export default function ShareButton() {
|
||||
const document = useDocument();
|
||||
const [message, setMessage] = useState < string | null > (null);
|
||||
|
||||
const onClick = async () => {
|
||||
const c = encodeURIComponent(JSON.stringify(document));
|
||||
location.hash = `#code/${btoa(c)}`;
|
||||
setMessage('The URL was updated. Copy it to share your current template.');
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton onClick={onClick}>
|
||||
<Tooltip title="Share current template">
|
||||
<IosShareOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<Snackbar
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
open={message !== null}
|
||||
onClose={onClose}
|
||||
message={message}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { html, json } from './highlighters';
|
||||
|
||||
type TextEditorPanelProps = {
|
||||
type: 'json' | 'html' | 'javascript';
|
||||
value: string;
|
||||
};
|
||||
export default function HighlightedCodePanel({ type, value }: TextEditorPanelProps) {
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
switch (type) {
|
||||
case 'html':
|
||||
html(value).then(setCode);
|
||||
return;
|
||||
case 'json':
|
||||
json(value).then(setCode);
|
||||
return;
|
||||
}
|
||||
}, [setCode, value, type]);
|
||||
|
||||
if (code === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
style={{ margin: 0, padding: 16, height: '100%', overflow: 'auto' }}
|
||||
dangerouslySetInnerHTML={{ __html: code }}
|
||||
onClick={(ev) => {
|
||||
const s = window.getSelection();
|
||||
if (s === null) {
|
||||
return;
|
||||
}
|
||||
s.selectAllChildren(ev.currentTarget);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import hljs from 'highlight.js';
|
||||
import jsonHighlighter from 'highlight.js/lib/languages/json';
|
||||
import xmlHighlighter from 'highlight.js/lib/languages/xml';
|
||||
import prettierPluginBabel from 'prettier/plugins/babel';
|
||||
import prettierPluginEstree from 'prettier/plugins/estree';
|
||||
import prettierPluginHtml from 'prettier/plugins/html';
|
||||
import { format } from 'prettier/standalone';
|
||||
|
||||
hljs.registerLanguage('json', jsonHighlighter);
|
||||
hljs.registerLanguage('html', xmlHighlighter);
|
||||
|
||||
export async function html(value: string): Promise<string> {
|
||||
const prettyValue = await format(value, {
|
||||
parser: 'html',
|
||||
plugins: [prettierPluginHtml],
|
||||
});
|
||||
return hljs.highlight(prettyValue, { language: 'html' }).value;
|
||||
}
|
||||
|
||||
export async function json(value: string): Promise<string> {
|
||||
const prettyValue = await format(value, {
|
||||
parser: 'json',
|
||||
printWidth: 0,
|
||||
trailingComma: 'all',
|
||||
plugins: [prettierPluginBabel, prettierPluginEstree],
|
||||
});
|
||||
return hljs.highlight(prettyValue, { language: 'javascript' }).value;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MonitorOutlined, PhoneIphoneOutlined } from '@mui/icons-material';
|
||||
import { Box, Stack, SxProps, ToggleButton, ToggleButtonGroup, Tooltip } from '@mui/material';
|
||||
import { Reader } from '@usewaypoint/email-builder';
|
||||
|
||||
import EditorBlock from '../../documents/editor/EditorBlock';
|
||||
import {
|
||||
setSelectedScreenSize,
|
||||
useDocument,
|
||||
useSelectedMainTab,
|
||||
useSelectedScreenSize,
|
||||
} from '../../documents/editor/EditorContext';
|
||||
import ToggleInspectorPanelButton from '../InspectorDrawer/ToggleInspectorPanelButton';
|
||||
|
||||
import DownloadJson from './DownloadJson';
|
||||
import HtmlPanel from './HtmlPanel';
|
||||
import ImportJson from './ImportJson';
|
||||
import JsonPanel from './JsonPanel';
|
||||
import MainTabsGroup from './MainTabsGroup';
|
||||
|
||||
export default function TemplatePanel() {
|
||||
const document = useDocument();
|
||||
const selectedMainTab = useSelectedMainTab();
|
||||
const selectedScreenSize = useSelectedScreenSize();
|
||||
|
||||
let mainBoxSx: SxProps = {
|
||||
height: '100%',
|
||||
};
|
||||
if (selectedScreenSize === 'mobile') {
|
||||
mainBoxSx = {
|
||||
...mainBoxSx,
|
||||
margin: '32px auto',
|
||||
width: 370,
|
||||
height: 800,
|
||||
boxShadow:
|
||||
'rgba(33, 36, 67, 0.04) 0px 10px 20px, rgba(33, 36, 67, 0.04) 0px 2px 6px, rgba(33, 36, 67, 0.04) 0px 0px 1px',
|
||||
};
|
||||
}
|
||||
|
||||
const handleScreenSizeChange = (_: unknown, value: unknown) => {
|
||||
switch (value) {
|
||||
case 'mobile':
|
||||
case 'desktop':
|
||||
setSelectedScreenSize(value);
|
||||
return;
|
||||
default:
|
||||
setSelectedScreenSize('desktop');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMainPanel = () => {
|
||||
switch (selectedMainTab) {
|
||||
case 'editor':
|
||||
return (
|
||||
<Box sx={mainBoxSx}>
|
||||
<EditorBlock id="root" />
|
||||
</Box>
|
||||
);
|
||||
case 'preview':
|
||||
return (
|
||||
<Box sx={mainBoxSx}>
|
||||
<Reader document={document} rootBlockId="root" />
|
||||
</Box>
|
||||
);
|
||||
case 'html':
|
||||
return <HtmlPanel />;
|
||||
case 'json':
|
||||
return <JsonPanel />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
height: 49,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
backgroundColor: 'white',
|
||||
top: 0,
|
||||
px: 1,
|
||||
}}
|
||||
direction="row"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
>
|
||||
<Stack px={2} direction="row" gap={2} width="100%" justifyContent="space-between" alignItems="center">
|
||||
<Stack direction="row" spacing={2}>
|
||||
<MainTabsGroup />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<DownloadJson />
|
||||
<ImportJson />
|
||||
<ToggleButtonGroup value={selectedScreenSize} exclusive size="small" onChange={handleScreenSizeChange}>
|
||||
<ToggleButton value="desktop">
|
||||
<Tooltip title="Desktop view">
|
||||
<MonitorOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
<ToggleButton value="mobile">
|
||||
<Tooltip title="Mobile view">
|
||||
<PhoneIphoneOutlined fontSize="small" />
|
||||
</Tooltip>
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<ToggleInspectorPanelButton />
|
||||
</Stack>
|
||||
<Box sx={{ height: 'calc(100vh - 49px)', overflow: 'auto', minWidth: 370 }}>{renderMainPanel()}</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Stack, useTheme } from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
import { TEditorConfiguration } from '../documents/editor/core';
|
||||
import { setDocument, subscribeDocument, useInspectorDrawerOpen, useSamplesDrawerOpen } from '../documents/editor/EditorContext';
|
||||
import { renderHtmlWithMeta } from '../utils';
|
||||
import InspectorDrawer, { INSPECTOR_DRAWER_WIDTH } from './InspectorDrawer';
|
||||
import TemplatePanel from './TemplatePanel';
|
||||
|
||||
export const DEFAULT_SOURCE: TEditorConfiguration = {
|
||||
"root": {
|
||||
"type": "EmailLayout",
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
|
||||
function useDrawerTransition(cssProperty: 'margin-left' | 'margin-right', open: boolean) {
|
||||
const { transitions } = useTheme();
|
||||
return transitions.create(cssProperty, {
|
||||
easing: !open ? transitions.easing.sharp : transitions.easing.easeOut,
|
||||
duration: !open ? transitions.duration.leavingScreen : transitions.duration.enteringScreen,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AppProps {
|
||||
// Initial configuration to load. Optional.
|
||||
data?: TEditorConfiguration,
|
||||
// Callback for any change in document. Optional.
|
||||
onChange?: (json: TEditorConfiguration, html: String) => void,
|
||||
// Optional height for the Stack component.
|
||||
height?: string,
|
||||
}
|
||||
|
||||
export default function App(props: AppProps) {
|
||||
const inspectorDrawerOpen = useInspectorDrawerOpen();
|
||||
const samplesDrawerOpen = useSamplesDrawerOpen();
|
||||
|
||||
const marginLeftTransition = useDrawerTransition('margin-left', samplesDrawerOpen);
|
||||
const marginRightTransition = useDrawerTransition('margin-right', inspectorDrawerOpen);
|
||||
|
||||
if (props.data) {
|
||||
setDocument(props.data)
|
||||
} else {
|
||||
setDocument(DEFAULT_SOURCE)
|
||||
}
|
||||
|
||||
if (props.onChange) {
|
||||
subscribeDocument ((document) => {
|
||||
props.onChange?.(
|
||||
document,
|
||||
renderHtmlWithMeta(document, { rootBlockId: 'root', outlook: Boolean(document.root?.data?.outlook) })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InspectorDrawer />
|
||||
|
||||
<Stack
|
||||
sx={{
|
||||
marginRight: inspectorDrawerOpen ? `${INSPECTOR_DRAWER_WIDTH}px` : 0,
|
||||
transition: [marginLeftTransition, marginRightTransition].join(', '),
|
||||
height: props.height ? props.height : 'auto',
|
||||
}}
|
||||
>
|
||||
<TemplatePanel />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ColumnsContainer as BaseColumnsContainer } from '@usewaypoint/block-columns-container';
|
||||
|
||||
import { useCurrentBlockId } from '../../editor/EditorBlock';
|
||||
import { setDocument, setSelectedBlockId } from '../../editor/EditorContext';
|
||||
import EditorChildrenIds, { EditorChildrenChange } from '../helpers/EditorChildrenIds';
|
||||
|
||||
import ColumnsContainerPropsSchema, { ColumnsContainerProps } from './ColumnsContainerPropsSchema';
|
||||
|
||||
const EMPTY_COLUMNS = [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }];
|
||||
|
||||
export default function ColumnsContainerEditor({ style, props }: ColumnsContainerProps) {
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
const { columns, ...restProps } = props ?? {};
|
||||
const columnsValue = columns ?? EMPTY_COLUMNS;
|
||||
|
||||
const updateColumn = (columnIndex: 0 | 1 | 2, { block, blockId, childrenIds }: EditorChildrenChange) => {
|
||||
const nColumns = [...columnsValue];
|
||||
nColumns[columnIndex] = { childrenIds };
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: 'ColumnsContainer',
|
||||
data: ColumnsContainerPropsSchema.parse({
|
||||
style,
|
||||
props: {
|
||||
...restProps,
|
||||
columns: nColumns,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseColumnsContainer
|
||||
props={restProps}
|
||||
style={style}
|
||||
columns={[
|
||||
<EditorChildrenIds childrenIds={columns?.[0]?.childrenIds} onChange={(change) => updateColumn(0, change)} />,
|
||||
<EditorChildrenIds childrenIds={columns?.[1]?.childrenIds} onChange={(change) => updateColumn(1, change)} />,
|
||||
<EditorChildrenIds childrenIds={columns?.[2]?.childrenIds} onChange={(change) => updateColumn(2, change)} />,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ColumnsContainerPropsSchema as BaseColumnsContainerPropsSchema } from '@usewaypoint/block-columns-container';
|
||||
|
||||
const BasePropsShape = BaseColumnsContainerPropsSchema.shape.props.unwrap().unwrap().shape;
|
||||
|
||||
const ColumnsContainerPropsSchema = z.object({
|
||||
style: BaseColumnsContainerPropsSchema.shape.style,
|
||||
props: z
|
||||
.object({
|
||||
...BasePropsShape,
|
||||
columns: z.tuple([
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
z.object({ childrenIds: z.array(z.string()) }),
|
||||
]),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ColumnsContainerProps = z.infer<typeof ColumnsContainerPropsSchema>;
|
||||
export default ColumnsContainerPropsSchema;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Container as BaseContainer } from '@usewaypoint/block-container';
|
||||
|
||||
import { useCurrentBlockId } from '../../editor/EditorBlock';
|
||||
import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
|
||||
import EditorChildrenIds from '../helpers/EditorChildrenIds';
|
||||
|
||||
import { ContainerProps } from './ContainerPropsSchema';
|
||||
|
||||
export default function ContainerEditor({ style, props }: ContainerProps) {
|
||||
const childrenIds = props?.childrenIds ?? [];
|
||||
|
||||
const document = useDocument();
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
return (
|
||||
<BaseContainer style={style}>
|
||||
<EditorChildrenIds
|
||||
childrenIds={childrenIds}
|
||||
onChange={({ block, blockId, childrenIds }) => {
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: 'Container',
|
||||
data: {
|
||||
...document[currentBlockId].data,
|
||||
props: { childrenIds: childrenIds },
|
||||
},
|
||||
},
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
}}
|
||||
/>
|
||||
</BaseContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ContainerPropsSchema as BaseContainerPropsSchema } from '@usewaypoint/block-container';
|
||||
|
||||
const ContainerPropsSchema = z.object({
|
||||
style: BaseContainerPropsSchema.shape.style,
|
||||
props: z
|
||||
.object({
|
||||
childrenIds: z.array(z.string()).optional().nullable(),
|
||||
})
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export default ContainerPropsSchema;
|
||||
|
||||
export type ContainerProps = z.infer<typeof ContainerPropsSchema>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useCurrentBlockId } from '../../editor/EditorBlock';
|
||||
import { setDocument, setSelectedBlockId, useDocument } from '../../editor/EditorContext';
|
||||
import EditorChildrenIds from '../helpers/EditorChildrenIds';
|
||||
|
||||
import { EmailLayoutProps } from './EmailLayoutPropsSchema';
|
||||
|
||||
function getFontFamily(fontFamily: EmailLayoutProps['fontFamily']) {
|
||||
const f = fontFamily ?? 'MODERN_SANS';
|
||||
switch (f) {
|
||||
case 'MODERN_SANS':
|
||||
return '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif';
|
||||
case 'BOOK_SANS':
|
||||
return 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif';
|
||||
case 'ORGANIC_SANS':
|
||||
return 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif';
|
||||
case 'GEOMETRIC_SANS':
|
||||
return 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif';
|
||||
case 'HEAVY_SANS':
|
||||
return 'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif';
|
||||
case 'ROUNDED_SANS':
|
||||
return 'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif';
|
||||
case 'MODERN_SERIF':
|
||||
return 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif';
|
||||
case 'BOOK_SERIF':
|
||||
return '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif';
|
||||
case 'MONOSPACE':
|
||||
return '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace';
|
||||
}
|
||||
}
|
||||
|
||||
export default function EmailLayoutEditor(props: EmailLayoutProps) {
|
||||
const childrenIds = props.childrenIds ?? [];
|
||||
const document = useDocument();
|
||||
const currentBlockId = useCurrentBlockId();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedBlockId(null);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: props.backdropColor ?? '#F5F5F5',
|
||||
color: props.textColor ?? '#262626',
|
||||
fontFamily: getFontFamily(props.fontFamily),
|
||||
fontSize: '16px',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '0.15008px',
|
||||
lineHeight: '1.5',
|
||||
margin: '0',
|
||||
padding: '32px 0',
|
||||
width: '100%',
|
||||
minHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<table
|
||||
align="center"
|
||||
width="100%"
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: props.canvasColor ?? '#FFFFFF',
|
||||
borderRadius: props.borderRadius ?? undefined,
|
||||
border: (() => {
|
||||
const v = props.borderColor;
|
||||
if (!v) {
|
||||
return undefined;
|
||||
}
|
||||
return `1px solid ${v}`;
|
||||
})(),
|
||||
}}
|
||||
role="presentation"
|
||||
cellSpacing="0"
|
||||
cellPadding="0"
|
||||
border={0}
|
||||
>
|
||||
<tbody>
|
||||
<tr style={{ width: '100%' }}>
|
||||
<td>
|
||||
<EditorChildrenIds
|
||||
childrenIds={childrenIds}
|
||||
onChange={({ block, blockId, childrenIds }) => {
|
||||
setDocument({
|
||||
[blockId]: block,
|
||||
[currentBlockId]: {
|
||||
type: 'EmailLayout',
|
||||
data: {
|
||||
...document[currentBlockId].data,
|
||||
childrenIds: childrenIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
setSelectedBlockId(blockId);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const COLOR_SCHEMA = z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/)
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const FONT_FAMILY_SCHEMA = z
|
||||
.enum([
|
||||
'MODERN_SANS',
|
||||
'BOOK_SANS',
|
||||
'ORGANIC_SANS',
|
||||
'GEOMETRIC_SANS',
|
||||
'HEAVY_SANS',
|
||||
'ROUNDED_SANS',
|
||||
'MODERN_SERIF',
|
||||
'BOOK_SERIF',
|
||||
'MONOSPACE',
|
||||
])
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const EmailLayoutPropsSchema = z.object({
|
||||
backdropColor: COLOR_SCHEMA,
|
||||
borderColor: COLOR_SCHEMA,
|
||||
borderRadius: z.number().optional().nullable(),
|
||||
canvasColor: COLOR_SCHEMA,
|
||||
textColor: COLOR_SCHEMA,
|
||||
fontFamily: FONT_FAMILY_SCHEMA,
|
||||
childrenIds: z.array(z.string()).optional().nullable(),
|
||||
outlook: z.boolean().optional().nullable(),
|
||||
});
|
||||
|
||||
export default EmailLayoutPropsSchema;
|
||||
|
||||
export type EmailLayoutProps = z.infer<typeof EmailLayoutPropsSchema>;
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Button, SxProps, Typography } from '@mui/material';
|
||||
|
||||
type BlockMenuButtonProps = {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const BUTTON_SX: SxProps = { p: 1.5, display: 'flex', flexDirection: 'column' };
|
||||
const ICON_SX: SxProps = {
|
||||
mb: 0.75,
|
||||
width: '100%',
|
||||
bgcolor: 'cadet.200',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
p: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'cadet.300',
|
||||
};
|
||||
|
||||
export default function BlockTypeButton({ label, icon, onClick }: BlockMenuButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
sx={BUTTON_SX}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Box sx={ICON_SX}>{icon}</Box>
|
||||
<Typography variant="body2">{label}</Typography>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Box, Menu } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../../editor/core';
|
||||
|
||||
import BlockButton from './BlockButton';
|
||||
import { BUTTONS } from './buttons';
|
||||
|
||||
type BlocksMenuProps = {
|
||||
anchorEl: HTMLElement | null;
|
||||
setAnchorEl: (v: HTMLElement | null) => void;
|
||||
onSelect: (block: TEditorBlock) => void;
|
||||
};
|
||||
export default function BlocksMenu({ anchorEl, setAnchorEl, onSelect }: BlocksMenuProps) {
|
||||
const onClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const onClick = (block: TEditorBlock) => {
|
||||
onSelect(block);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
if (anchorEl === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open
|
||||
anchorEl={anchorEl}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Box sx={{ p: 1, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr' }}>
|
||||
{BUTTONS.map((k, i) => (
|
||||
<BlockButton key={i} label={k.label} icon={k.icon} onClick={() => onClick(k.block())} />
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { AddOutlined } from '@mui/icons-material';
|
||||
import { Fade, IconButton } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
buttonElement: HTMLElement | null;
|
||||
onClick: () => void;
|
||||
};
|
||||
export default function DividerButton({ buttonElement, onClick }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function listener({ clientX, clientY }: MouseEvent) {
|
||||
if (!buttonElement) {
|
||||
return;
|
||||
}
|
||||
const rect = buttonElement.getBoundingClientRect();
|
||||
const rectY = rect.y;
|
||||
const bottomX = rect.x;
|
||||
const topX = bottomX + rect.width;
|
||||
|
||||
if (Math.abs(clientY - rectY) < 20) {
|
||||
if (bottomX < clientX && clientX < topX) {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setVisible(false);
|
||||
}
|
||||
window.addEventListener('mousemove', listener);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', listener);
|
||||
};
|
||||
}, [buttonElement, setVisible]);
|
||||
|
||||
return (
|
||||
<Fade in={visible}>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{
|
||||
p: 0.12,
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-10px)',
|
||||
bgcolor: 'brand.blue',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover, &:active, &:focus': {
|
||||
bgcolor: 'brand.blue',
|
||||
color: 'primary.contrastText',
|
||||
},
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<AddOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AddOutlined } from '@mui/icons-material';
|
||||
import { ButtonBase } from '@mui/material';
|
||||
|
||||
type Props = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export default function PlaceholderButton({ onClick }: Props) {
|
||||
return (
|
||||
<ButtonBase
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignContent: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 48,
|
||||
width: '100%',
|
||||
bgcolor: 'rgba(0,0,0, 0.05)',
|
||||
}}
|
||||
>
|
||||
<AddOutlined
|
||||
sx={{
|
||||
p: 0.12,
|
||||
bgcolor: 'brand.blue',
|
||||
borderRadius: 24,
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
fontSize="small"
|
||||
/>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
AccountCircleOutlined,
|
||||
Crop32Outlined,
|
||||
HMobiledataOutlined,
|
||||
HorizontalRuleOutlined,
|
||||
HtmlOutlined,
|
||||
ImageOutlined,
|
||||
LibraryAddOutlined,
|
||||
NotesOutlined,
|
||||
SmartButtonOutlined,
|
||||
ViewColumnOutlined,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
import { TEditorBlock } from '../../../../editor/core';
|
||||
|
||||
type TButtonProps = {
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
block: () => TEditorBlock;
|
||||
};
|
||||
export const BUTTONS: TButtonProps[] = [
|
||||
{
|
||||
label: 'Heading',
|
||||
icon: <HMobiledataOutlined />,
|
||||
block: () => ({
|
||||
type: 'Heading',
|
||||
data: {
|
||||
props: { text: 'Heading' },
|
||||
style: {
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Text',
|
||||
icon: <NotesOutlined />,
|
||||
block: () => ({
|
||||
type: 'Text',
|
||||
data: {
|
||||
props: { text: 'My new text block' },
|
||||
style: {
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
label: 'Button',
|
||||
icon: <SmartButtonOutlined />,
|
||||
block: () => ({
|
||||
type: 'Button',
|
||||
data: {
|
||||
props: {
|
||||
text: 'Button',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Image',
|
||||
icon: <ImageOutlined />,
|
||||
block: () => ({
|
||||
type: 'Image',
|
||||
data: {
|
||||
props: {
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Placeholder_view_vector.svg',
|
||||
alt: 'Sample product',
|
||||
contentAlignment: 'middle',
|
||||
linkHref: null,
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Avatar',
|
||||
icon: <AccountCircleOutlined />,
|
||||
block: () => ({
|
||||
type: 'Avatar',
|
||||
data: {
|
||||
props: {
|
||||
imageUrl: 'https://upload.wikimedia.org/wikipedia/commons/8/89/Portrait_Placeholder.png',
|
||||
shape: 'circle',
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Divider',
|
||||
icon: <HorizontalRuleOutlined />,
|
||||
block: () => ({
|
||||
type: 'Divider',
|
||||
data: {
|
||||
style: { padding: { top: 16, right: 0, bottom: 16, left: 0 } },
|
||||
props: {
|
||||
lineColor: '#CCCCCC',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Spacer',
|
||||
icon: <Crop32Outlined />,
|
||||
block: () => ({
|
||||
type: 'Spacer',
|
||||
data: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Html',
|
||||
icon: <HtmlOutlined />,
|
||||
block: () => ({
|
||||
type: 'Html',
|
||||
data: {
|
||||
props: { contents: '<strong>Hello world</strong>' },
|
||||
style: {
|
||||
fontSize: 16,
|
||||
textAlign: null,
|
||||
padding: { top: 16, bottom: 16, left: 24, right: 24 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Columns',
|
||||
icon: <ViewColumnOutlined />,
|
||||
block: () => ({
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
props: {
|
||||
columnsGap: 16,
|
||||
columnsCount: 3,
|
||||
columns: [{ childrenIds: [] }, { childrenIds: [] }, { childrenIds: [] }],
|
||||
},
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Container',
|
||||
icon: <LibraryAddOutlined />,
|
||||
block: () => ({
|
||||
type: 'Container',
|
||||
data: {
|
||||
style: { padding: { top: 16, bottom: 16, left: 24, right: 24 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
// { label: 'ProgressBar', icon: <ProgressBarOutlined />, block: () => ({}) },
|
||||
// { label: 'LoopContainer', icon: <ViewListOutlined />, block: () => ({}) },
|
||||
];
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { TEditorBlock } from '../../../../editor/core';
|
||||
|
||||
import BlocksMenu from './BlocksMenu';
|
||||
import DividerButton from './DividerButton';
|
||||
import PlaceholderButton from './PlaceholderButton';
|
||||
|
||||
type Props = {
|
||||
placeholder?: boolean;
|
||||
onSelect: (block: TEditorBlock) => void;
|
||||
};
|
||||
export default function AddBlockButton({ onSelect, placeholder }: Props) {
|
||||
const [menuAnchorEl, setMenuAnchorEl] = useState<HTMLElement | null>(null);
|
||||
const [buttonElement, setButtonElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
const handleButtonClick = () => {
|
||||
setMenuAnchorEl(buttonElement);
|
||||
};
|
||||
|
||||
const renderButton = () => {
|
||||
if (placeholder) {
|
||||
return <PlaceholderButton onClick={handleButtonClick} />;
|
||||
} else {
|
||||
return <DividerButton buttonElement={buttonElement} onClick={handleButtonClick} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setButtonElement} style={{ position: 'relative' }}>
|
||||
{renderButton()}
|
||||
</div>
|
||||
<BlocksMenu anchorEl={menuAnchorEl} setAnchorEl={setMenuAnchorEl} onSelect={onSelect} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { TEditorBlock } from '../../../editor/core';
|
||||
import EditorBlock from '../../../editor/EditorBlock';
|
||||
|
||||
import AddBlockButton from './AddBlockMenu';
|
||||
|
||||
export type EditorChildrenChange = {
|
||||
blockId: string;
|
||||
block: TEditorBlock;
|
||||
childrenIds: string[];
|
||||
};
|
||||
|
||||
function generateId() {
|
||||
return `block-${Date.now()}`;
|
||||
}
|
||||
|
||||
export type EditorChildrenIdsProps = {
|
||||
childrenIds: string[] | null | undefined;
|
||||
onChange: (val: EditorChildrenChange) => void;
|
||||
};
|
||||
export default function EditorChildrenIds({ childrenIds, onChange }: EditorChildrenIdsProps) {
|
||||
const appendBlock = (block: TEditorBlock) => {
|
||||
const blockId = generateId();
|
||||
return onChange({
|
||||
blockId,
|
||||
block,
|
||||
childrenIds: [...(childrenIds || []), blockId],
|
||||
});
|
||||
};
|
||||
|
||||
const insertBlock = (block: TEditorBlock, index: number) => {
|
||||
const blockId = generateId();
|
||||
const newChildrenIds = [...(childrenIds || [])];
|
||||
newChildrenIds.splice(index, 0, blockId);
|
||||
return onChange({
|
||||
blockId,
|
||||
block,
|
||||
childrenIds: newChildrenIds,
|
||||
});
|
||||
};
|
||||
|
||||
if (!childrenIds || childrenIds.length === 0) {
|
||||
return <AddBlockButton placeholder onSelect={appendBlock} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{childrenIds.map((childId, i) => (
|
||||
<Fragment key={childId}>
|
||||
<AddBlockButton onSelect={(block) => insertBlock(block, i)} />
|
||||
<EditorBlock id={childId} />
|
||||
</Fragment>
|
||||
))}
|
||||
<AddBlockButton onSelect={appendBlock} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type TStyle = {
|
||||
backgroundColor?: any;
|
||||
borderColor?: any;
|
||||
borderRadius?: any;
|
||||
color?: any;
|
||||
fontFamily?: any;
|
||||
fontSize?: any;
|
||||
fontWeight?: any;
|
||||
padding?: any;
|
||||
textAlign?: any;
|
||||
};
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
import { useCurrentBlockId } from '../../../editor/EditorBlock';
|
||||
import { setSelectedBlockId, useSelectedBlockId } from '../../../editor/EditorContext';
|
||||
|
||||
import TuneMenu from './TuneMenu';
|
||||
|
||||
type TEditorBlockWrapperProps = {
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function EditorBlockWrapper({ children }: TEditorBlockWrapperProps) {
|
||||
const selectedBlockId = useSelectedBlockId();
|
||||
const [mouseInside, setMouseInside] = useState(false);
|
||||
const blockId = useCurrentBlockId();
|
||||
|
||||
let outline: CSSProperties['outline'];
|
||||
if (selectedBlockId === blockId) {
|
||||
outline = '2px solid rgba(0,121,204, 1)';
|
||||
} else if (mouseInside) {
|
||||
outline = '2px solid rgba(0,121,204, 0.3)';
|
||||
}
|
||||
|
||||
const renderMenu = () => {
|
||||
if (selectedBlockId !== blockId) {
|
||||
return null;
|
||||
}
|
||||
return <TuneMenu blockId={blockId} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
maxWidth: '100%',
|
||||
outlineOffset: '-1px',
|
||||
outline,
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
setMouseInside(true);
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setMouseInside(false);
|
||||
}}
|
||||
onClick={(ev) => {
|
||||
setSelectedBlockId(blockId);
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
{renderMenu()}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
|
||||
import { TStyle } from '../TStyle';
|
||||
|
||||
type TReaderBlockWrapperProps = {
|
||||
style: TStyle;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
export default function ReaderBlockWrapper({ style, children }: TReaderBlockWrapperProps) {
|
||||
const { padding, borderColor, ...restStyle } = style;
|
||||
const cssStyle: CSSProperties = {
|
||||
...restStyle,
|
||||
};
|
||||
|
||||
if (padding) {
|
||||
const { top, bottom, left, right } = padding;
|
||||
cssStyle.padding = `${top}px ${right}px ${bottom}px ${left}px`;
|
||||
}
|
||||
|
||||
if (borderColor) {
|
||||
cssStyle.border = `1px solid ${borderColor}`;
|
||||
}
|
||||
|
||||
return <div style={{ maxWidth: '100%', ...cssStyle }}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ArrowDownwardOutlined, ArrowUpwardOutlined, ContentCopyOutlined, DeleteOutlined } from '@mui/icons-material';
|
||||
import { IconButton, Paper, Stack, SxProps, Tooltip } from '@mui/material';
|
||||
|
||||
import { TEditorBlock } from '../../../editor/core';
|
||||
import { resetDocument, setSelectedBlockId, useDocument } from '../../../editor/EditorContext';
|
||||
import { ColumnsContainerProps } from '../../ColumnsContainer/ColumnsContainerPropsSchema';
|
||||
|
||||
const sx: SxProps = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: -56,
|
||||
borderRadius: 64,
|
||||
paddingX: 0.5,
|
||||
paddingY: 1
|
||||
};
|
||||
|
||||
type Props = {
|
||||
blockId: string;
|
||||
};
|
||||
export default function TuneMenu({ blockId }: Props) {
|
||||
const document = useDocument();
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
const filterChildrenIds = (childrenIds: string[] | null | undefined) => {
|
||||
if (!childrenIds) {
|
||||
return childrenIds;
|
||||
}
|
||||
return childrenIds.filter((f) => f !== blockId);
|
||||
};
|
||||
const nDocument: typeof document = { ...document };
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const block = b as TEditorBlock;
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
switch (block.type) {
|
||||
case 'EmailLayout':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
childrenIds: filterChildrenIds(block.data.childrenIds),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'Container':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
props: {
|
||||
...block.data.props,
|
||||
childrenIds: filterChildrenIds(block.data.props?.childrenIds),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'ColumnsContainer':
|
||||
nDocument[id] = {
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
style: block.data.style,
|
||||
props: {
|
||||
...block.data.props,
|
||||
columns: block.data.props?.columns?.map((c) => ({
|
||||
childrenIds: filterChildrenIds(c.childrenIds),
|
||||
})),
|
||||
},
|
||||
} as ColumnsContainerProps,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = block;
|
||||
}
|
||||
}
|
||||
delete nDocument[blockId];
|
||||
resetDocument(nDocument);
|
||||
};
|
||||
|
||||
const handleDuplicateClick = () => {
|
||||
const block = document[blockId] as TEditorBlock;
|
||||
if (!block) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively clone a block and all its descendants, assigning new IDs.
|
||||
const clones: Record<string, TEditorBlock> = {};
|
||||
const cloneBlock = (srcId: string): string => {
|
||||
const newId = `block-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const src = document[srcId] as TEditorBlock;
|
||||
if (!src) {
|
||||
return newId;
|
||||
}
|
||||
const cloned: TEditorBlock = JSON.parse(JSON.stringify(src));
|
||||
|
||||
// Remap childrenIds for container types so each child is also cloned.
|
||||
if (cloned.type === 'Container' && cloned.data.props?.childrenIds) {
|
||||
cloned.data.props.childrenIds = cloned.data.props.childrenIds.map(cloneBlock);
|
||||
} else if (cloned.type === 'ColumnsContainer' && cloned.data.props?.columns) {
|
||||
cloned.data.props.columns = cloned.data.props.columns.map((c) => ({
|
||||
childrenIds: c.childrenIds?.map(cloneBlock) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
clones[newId] = cloned;
|
||||
return newId;
|
||||
};
|
||||
const newBlockId = cloneBlock(blockId);
|
||||
|
||||
const insertAfter = (ids: string[] | null | undefined) => {
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
const index = ids.indexOf(blockId);
|
||||
if (index < 0) {
|
||||
return ids;
|
||||
}
|
||||
const newIds = [...ids];
|
||||
newIds.splice(index + 1, 0, newBlockId);
|
||||
return newIds;
|
||||
};
|
||||
|
||||
const nDocument: typeof document = {
|
||||
...document,
|
||||
...clones,
|
||||
};
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const entry = b as TEditorBlock;
|
||||
if (id === blockId || id in clones) {
|
||||
continue;
|
||||
}
|
||||
switch (entry.type) {
|
||||
case 'EmailLayout':
|
||||
nDocument[id] = {
|
||||
...entry,
|
||||
data: {
|
||||
...entry.data,
|
||||
childrenIds: insertAfter(entry.data.childrenIds),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'Container':
|
||||
nDocument[id] = {
|
||||
...entry,
|
||||
data: {
|
||||
...entry.data,
|
||||
props: {
|
||||
...entry.data.props,
|
||||
childrenIds: insertAfter(entry.data.props?.childrenIds),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'ColumnsContainer':
|
||||
nDocument[id] = {
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
style: entry.data.style,
|
||||
props: {
|
||||
...entry.data.props,
|
||||
columns: entry.data.props?.columns?.map((c) => ({
|
||||
childrenIds: insertAfter(c.childrenIds),
|
||||
})),
|
||||
},
|
||||
} as ColumnsContainerProps,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = entry;
|
||||
}
|
||||
}
|
||||
resetDocument(nDocument);
|
||||
setSelectedBlockId(newBlockId);
|
||||
};
|
||||
|
||||
const handleMoveClick = (direction: 'up' | 'down') => {
|
||||
const moveChildrenIds = (ids: string[] | null | undefined) => {
|
||||
if (!ids) {
|
||||
return ids;
|
||||
}
|
||||
const index = ids.indexOf(blockId);
|
||||
if (index < 0) {
|
||||
return ids;
|
||||
}
|
||||
const childrenIds = [...ids];
|
||||
if (direction === 'up' && index > 0) {
|
||||
[childrenIds[index], childrenIds[index - 1]] = [childrenIds[index - 1], childrenIds[index]];
|
||||
} else if (direction === 'down' && index < childrenIds.length - 1) {
|
||||
[childrenIds[index], childrenIds[index + 1]] = [childrenIds[index + 1], childrenIds[index]];
|
||||
}
|
||||
return childrenIds;
|
||||
};
|
||||
const nDocument: typeof document = { ...document };
|
||||
for (const [id, b] of Object.entries(nDocument)) {
|
||||
const block = b as TEditorBlock;
|
||||
if (id === blockId) {
|
||||
continue;
|
||||
}
|
||||
switch (block.type) {
|
||||
case 'EmailLayout':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
childrenIds: moveChildrenIds(block.data.childrenIds),
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'Container':
|
||||
nDocument[id] = {
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
props: {
|
||||
...block.data.props,
|
||||
childrenIds: moveChildrenIds(block.data.props?.childrenIds),
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
case 'ColumnsContainer':
|
||||
nDocument[id] = {
|
||||
type: 'ColumnsContainer',
|
||||
data: {
|
||||
style: block.data.style,
|
||||
props: {
|
||||
...block.data.props,
|
||||
columns: block.data.props?.columns?.map((c) => ({
|
||||
childrenIds: moveChildrenIds(c.childrenIds),
|
||||
})),
|
||||
},
|
||||
} as ColumnsContainerProps,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
nDocument[id] = block;
|
||||
}
|
||||
}
|
||||
|
||||
resetDocument(nDocument);
|
||||
setSelectedBlockId(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={sx} onClick={(ev) => ev.stopPropagation()}>
|
||||
<Stack>
|
||||
<Tooltip title="Move up" placement="left-start">
|
||||
<IconButton onClick={() => handleMoveClick('up')} sx={{ color: 'text.primary' }}>
|
||||
<ArrowUpwardOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Move down" placement="left-start">
|
||||
<IconButton onClick={() => handleMoveClick('down')} sx={{ color: 'text.primary' }}>
|
||||
<ArrowDownwardOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate" placement="left-start">
|
||||
<IconButton onClick={handleDuplicateClick} sx={{ color: 'text.primary' }}>
|
||||
<ContentCopyOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete" placement="left-start">
|
||||
<IconButton onClick={handleDeleteClick} sx={{ color: 'text.primary' }}>
|
||||
<DeleteOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export const FONT_FAMILIES = [
|
||||
{
|
||||
key: 'MODERN_SANS',
|
||||
label: 'Modern sans',
|
||||
value: '"Helvetica Neue", "Arial Nova", "Nimbus Sans", Arial, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'BOOK_SANS',
|
||||
label: 'Book sans',
|
||||
value: 'Optima, Candara, "Noto Sans", source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'ORGANIC_SANS',
|
||||
label: 'Organic sans',
|
||||
value: 'Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'GEOMETRIC_SANS',
|
||||
label: 'Geometric sans',
|
||||
value: 'Avenir, "Avenir Next LT Pro", Montserrat, Corbel, "URW Gothic", source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'HEAVY_SANS',
|
||||
label: 'Heavy sans',
|
||||
value:
|
||||
'Bahnschrift, "DIN Alternate", "Franklin Gothic Medium", "Nimbus Sans Narrow", sans-serif-condensed, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'ROUNDED_SANS',
|
||||
label: 'Rounded sans',
|
||||
value:
|
||||
'ui-rounded, "Hiragino Maru Gothic ProN", Quicksand, Comfortaa, Manjari, "Arial Rounded MT Bold", Calibri, source-sans-pro, sans-serif',
|
||||
},
|
||||
{
|
||||
key: 'MODERN_SERIF',
|
||||
label: 'Modern serif',
|
||||
value: 'Charter, "Bitstream Charter", "Sitka Text", Cambria, serif',
|
||||
},
|
||||
{
|
||||
key: 'BOOK_SERIF',
|
||||
label: 'Book serif',
|
||||
value: '"Iowan Old Style", "Palatino Linotype", "URW Palladio L", P052, serif',
|
||||
},
|
||||
{
|
||||
key: 'MONOSPACE',
|
||||
label: 'Monospace',
|
||||
value: '"Nimbus Mono PS", "Courier New", "Cutive Mono", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
export const FONT_FAMILY_NAMES = [
|
||||
'MODERN_SANS',
|
||||
'BOOK_SANS',
|
||||
'ORGANIC_SANS',
|
||||
'GEOMETRIC_SANS',
|
||||
'HEAVY_SANS',
|
||||
'ROUNDED_SANS',
|
||||
'MODERN_SERIF',
|
||||
'BOOK_SERIF',
|
||||
'MONOSPACE',
|
||||
] as const;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { FONT_FAMILY_NAMES } from './fontFamily';
|
||||
|
||||
export function zColor() {
|
||||
return z.string().regex(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
|
||||
export function zFontFamily() {
|
||||
return z.enum(FONT_FAMILY_NAMES);
|
||||
}
|
||||
|
||||
export function zFontWeight() {
|
||||
return z.enum(['bold', 'normal']);
|
||||
}
|
||||
|
||||
export function zTextAlign() {
|
||||
return z.enum(['left', 'center', 'right']);
|
||||
}
|
||||
|
||||
export function zPadding() {
|
||||
return z.object({
|
||||
top: z.number(),
|
||||
bottom: z.number(),
|
||||
right: z.number(),
|
||||
left: z.number(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
import { EditorBlock as CoreEditorBlock } from './core';
|
||||
import { useDocument } from './EditorContext';
|
||||
|
||||
const EditorBlockContext = createContext<string | null>(null);
|
||||
export const useCurrentBlockId = () => useContext(EditorBlockContext)!;
|
||||
|
||||
type EditorBlockProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id - Block id
|
||||
* @returns EditorBlock component that loads data from the EditorDocumentContext
|
||||
*/
|
||||
export default function EditorBlock({ id }: EditorBlockProps) {
|
||||
const document = useDocument();
|
||||
const block = document[id];
|
||||
if (!block) {
|
||||
throw new Error('Could not find block');
|
||||
}
|
||||
return (
|
||||
<EditorBlockContext.Provider value={id}>
|
||||
<CoreEditorBlock {...block} />
|
||||
</EditorBlockContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { create } from 'zustand';
|
||||
import { subscribeWithSelector } from 'zustand/middleware';
|
||||
|
||||
import getConfiguration from '../../getConfiguration';
|
||||
|
||||
import { TEditorConfiguration } from './core';
|
||||
|
||||
type TValue = {
|
||||
document: TEditorConfiguration;
|
||||
|
||||
selectedBlockId: string | null;
|
||||
selectedSidebarTab: 'block-configuration' | 'styles';
|
||||
selectedMainTab: 'editor' | 'preview' | 'json' | 'html';
|
||||
selectedScreenSize: 'desktop' | 'mobile';
|
||||
|
||||
inspectorDrawerOpen: boolean;
|
||||
samplesDrawerOpen: boolean;
|
||||
};
|
||||
|
||||
const editorStateStore = create(subscribeWithSelector<TValue>(() => ({
|
||||
document: getConfiguration(window.location.hash),
|
||||
selectedBlockId: null,
|
||||
selectedSidebarTab: 'styles',
|
||||
selectedMainTab: 'editor',
|
||||
selectedScreenSize: 'desktop',
|
||||
|
||||
inspectorDrawerOpen: true,
|
||||
samplesDrawerOpen: true,
|
||||
})));
|
||||
|
||||
export function useDocument() {
|
||||
return editorStateStore((s) => s.document);
|
||||
}
|
||||
|
||||
export function subscribeDocument (listener: (selectedState: TEditorConfiguration, previousSelectedState: TEditorConfiguration) => void) {
|
||||
editorStateStore.subscribe((state) => state.document, listener)
|
||||
}
|
||||
|
||||
export function useSelectedBlockId() {
|
||||
return editorStateStore((s) => s.selectedBlockId);
|
||||
}
|
||||
|
||||
export function useSelectedScreenSize() {
|
||||
return editorStateStore((s) => s.selectedScreenSize);
|
||||
}
|
||||
|
||||
export function useSelectedMainTab() {
|
||||
return editorStateStore((s) => s.selectedMainTab);
|
||||
}
|
||||
|
||||
export function setSelectedMainTab(selectedMainTab: TValue['selectedMainTab']) {
|
||||
return editorStateStore.setState({ selectedMainTab });
|
||||
}
|
||||
|
||||
export function useSelectedSidebarTab() {
|
||||
return editorStateStore((s) => s.selectedSidebarTab);
|
||||
}
|
||||
|
||||
export function useInspectorDrawerOpen() {
|
||||
return editorStateStore((s) => s.inspectorDrawerOpen);
|
||||
}
|
||||
|
||||
export function useSamplesDrawerOpen() {
|
||||
return editorStateStore((s) => s.samplesDrawerOpen);
|
||||
}
|
||||
|
||||
export function setSelectedBlockId(selectedBlockId: TValue['selectedBlockId']) {
|
||||
const selectedSidebarTab = selectedBlockId === null ? 'styles' : 'block-configuration';
|
||||
const options: Partial<TValue> = {};
|
||||
if (selectedBlockId !== null) {
|
||||
options.inspectorDrawerOpen = true;
|
||||
}
|
||||
return editorStateStore.setState({
|
||||
selectedBlockId,
|
||||
selectedSidebarTab,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function setSidebarTab(selectedSidebarTab: TValue['selectedSidebarTab']) {
|
||||
return editorStateStore.setState({ selectedSidebarTab });
|
||||
}
|
||||
|
||||
export function resetDocument(document: TValue['document']) {
|
||||
return editorStateStore.setState({
|
||||
document,
|
||||
selectedSidebarTab: 'styles',
|
||||
selectedBlockId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export function setDocument(document: TValue['document']) {
|
||||
const originalDocument = editorStateStore.getState().document;
|
||||
return editorStateStore.setState({
|
||||
document: {
|
||||
...originalDocument,
|
||||
...document,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleInspectorDrawerOpen() {
|
||||
const inspectorDrawerOpen = !editorStateStore.getState().inspectorDrawerOpen;
|
||||
return editorStateStore.setState({ inspectorDrawerOpen });
|
||||
}
|
||||
|
||||
export function toggleSamplesDrawerOpen() {
|
||||
const samplesDrawerOpen = !editorStateStore.getState().samplesDrawerOpen;
|
||||
return editorStateStore.setState({ samplesDrawerOpen });
|
||||
}
|
||||
|
||||
export function setSelectedScreenSize(selectedScreenSize: TValue['selectedScreenSize']) {
|
||||
return editorStateStore.setState({ selectedScreenSize });
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Avatar, AvatarPropsSchema } from '@usewaypoint/block-avatar';
|
||||
import { Button, ButtonPropsSchema } from '@usewaypoint/block-button';
|
||||
import { Divider, DividerPropsSchema } from '@usewaypoint/block-divider';
|
||||
import { Heading, HeadingPropsSchema } from '@usewaypoint/block-heading';
|
||||
import { Html, HtmlPropsSchema } from '@usewaypoint/block-html';
|
||||
import { Image, ImagePropsSchema } from '@usewaypoint/block-image';
|
||||
import { Spacer, SpacerPropsSchema } from '@usewaypoint/block-spacer';
|
||||
import { Text, TextPropsSchema } from '@usewaypoint/block-text';
|
||||
import {
|
||||
buildBlockComponent,
|
||||
buildBlockConfigurationDictionary,
|
||||
buildBlockConfigurationSchema,
|
||||
} from '@usewaypoint/document-core';
|
||||
|
||||
import ColumnsContainerEditor from '../blocks/ColumnsContainer/ColumnsContainerEditor';
|
||||
import ColumnsContainerPropsSchema from '../blocks/ColumnsContainer/ColumnsContainerPropsSchema';
|
||||
import ContainerEditor from '../blocks/Container/ContainerEditor';
|
||||
import ContainerPropsSchema from '../blocks/Container/ContainerPropsSchema';
|
||||
import EmailLayoutEditor from '../blocks/EmailLayout/EmailLayoutEditor';
|
||||
import EmailLayoutPropsSchema from '../blocks/EmailLayout/EmailLayoutPropsSchema';
|
||||
import EditorBlockWrapper from '../blocks/helpers/block-wrappers/EditorBlockWrapper';
|
||||
|
||||
// Adds an opt-in `embed` flag to the upstream Image props. The renderer
|
||||
// (frontend/email-builder/src/utils.tsx) re-tags marked <img>s with
|
||||
// `data-embed` and the backend resolves the src filename to a media item.
|
||||
export const ImgPropsSchema = ImagePropsSchema.extend({
|
||||
props: z.object({
|
||||
width: z.number().nullable().optional(),
|
||||
height: z.number().nullable().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
alt: z.string().nullable().optional(),
|
||||
linkHref: z.string().nullable().optional(),
|
||||
contentAlignment: z.enum(['top', 'middle', 'bottom']).nullable().optional(),
|
||||
embed: z.boolean().nullable().optional(),
|
||||
}).nullable().optional(),
|
||||
});
|
||||
export type EagleCastImageProps = z.infer<typeof ImgPropsSchema>;
|
||||
|
||||
const EDITOR_DICTIONARY = buildBlockConfigurationDictionary({
|
||||
Avatar: {
|
||||
schema: AvatarPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Avatar {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Button: {
|
||||
schema: ButtonPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Button {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Container: {
|
||||
schema: ContainerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<ContainerEditor {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
ColumnsContainer: {
|
||||
schema: ColumnsContainerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<ColumnsContainerEditor {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Heading: {
|
||||
schema: HeadingPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Heading {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Html: {
|
||||
schema: HtmlPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Html {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Image: {
|
||||
schema: ImgPropsSchema,
|
||||
Component: (data) => {
|
||||
const props = {
|
||||
...data,
|
||||
props: {
|
||||
...data.props,
|
||||
url: data.props?.url ?? 'https://placehold.co/600x400@2x/F8F8F8/CCC?text=Your%20image',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<EditorBlockWrapper>
|
||||
<Image {...props} />
|
||||
</EditorBlockWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
Text: {
|
||||
schema: TextPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Text {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
EmailLayout: {
|
||||
schema: EmailLayoutPropsSchema,
|
||||
Component: (p) => <EmailLayoutEditor {...p} />,
|
||||
},
|
||||
Spacer: {
|
||||
schema: SpacerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Spacer {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
Divider: {
|
||||
schema: DividerPropsSchema,
|
||||
Component: (props) => (
|
||||
<EditorBlockWrapper>
|
||||
<Divider {...props} />
|
||||
</EditorBlockWrapper>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
export const EditorBlock = buildBlockComponent(EDITOR_DICTIONARY);
|
||||
export const EditorBlockSchema = buildBlockConfigurationSchema(EDITOR_DICTIONARY);
|
||||
export const EditorConfigurationSchema = z.record(z.string(), EditorBlockSchema);
|
||||
|
||||
export type TEditorBlock = z.infer<typeof EditorBlockSchema>;
|
||||
export type TEditorConfiguration = Record<string, TEditorBlock>;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user