Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
The primary source of the Code of Conduct is at [mautic.org](https://www.mautic.org/code-of-conduct/) - it is reproduced here for reference.
## 1. Purpose
A primary goal of the Mautic community is to support you and your business in the development, use and implementation of Mautic. Its to be inclusive and add value to the largest number of participants, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all.
This code of conduct outlines our expectations for all those who participate in our community, whether in-person or online, as well as the consequences for unacceptable behavior.
Your participation is contingent upon following these guidelines in all Mautic activities, including but not limited to:
* Using Mautic community resources.
* Working with other Mauticians and other Mautic community participants whether virtually or co-located.
* Representing Mautic at public events.
* Representing Mautic in social media (official accounts, personal accounts, Facebook pages and groups).
* Participating in Mautic sprints and training events.
* Participating in Mautic-related forums, mailing lists, wikis, websites, chat channels, bugs, group or person-to-person meetings, and Mautic-related correspondence.
We invite all those who participate in Mautic activities online to help us create safe and positive experiences for everyone, everywhere.
## 2. Open Source & Culture Citizenship
A supplemental goal of this Code of Conduct is to increase open source and culture citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community.
Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society.
If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, please recognize their efforts.
## 3. Welcoming to all
We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience or job role, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, national origin, citizenship and immigration status, neurodiversity, mental health or socio-economic status.
## 4. Expected Behavior
The following behaviors are expected and requested of all community members:
* Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community.
* Exercise consideration and respect in your speech and actions.
* Attempt collaboration before conflict.
* Guide conversations toward issue resolution.
* Refrain from demeaning, discriminatory, or harassing behavior and speech.
Alert Mautic team members if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential.
## 5. Unacceptable Behavior
The following behaviors are considered harassment and are unacceptable within our community:
* **Violence and Threats of Violence** are not acceptable - online or offline. This includes incitement of violence toward any individual, including encouraging a person to commit self-harm. This also includes posting or threatening to post other peoples personally identifying information (“doxxing”) online.
* **Public or private harassment** is never acceptable in any form.
* **Personal Attacks** Conflicts will inevitably arise, but frustration should never turn into a personal attack. It is not okay to insult, demean or belittle others. Attacking someone for their opinions, beliefs and ideas is not acceptable. It is important to speak directly when we disagree and when we think we need to improve, but such discussions must be conducted respectfully and professionally, remaining focused on the issue at hand.
* **Derogatory Language** Hurtful or harmful language is never acceptable in any context related to: background, family status, gender, gender identity or expression, marital status, sex, sexual orientation, personal appearance, body size, native language, age, ability, neurodiversity, mental health, race and/or ethnicity, national origin, citizenship and immigration status, socioeconomic status, religion, geographic location.
* **Unwelcome Sexual Attention or Physical Contact** Unwelcome sexual attention or unwelcome physical contact is not acceptable. This includes sexualized comments, jokes or imagery in interactions, communications or presentation materials, as well as inappropriate touching, groping, or sexual advances. This includes touching a person without permission, including sensitive areas such as their hair, pregnant stomach, mobility device (wheelchair, scooter, etc) or tattoos. This also includes physically blocking or intimidating another person. Physical contact or simulated physical contact (such as emojis like “kiss”) without affirmative consent is not acceptable. This includes sharing or distribution of sexualized images or text.
* **Disruptive Behavior** Sustained disruption of events, forums, or meetings, including talks and presentations, will not be tolerated. This includes spamming community discussions with the solicitation of unwanted products or services.
* **Influencing Disruptive Behavior** We will treat influencing or leading such activities the same way we treat the activities themselves, and thus the same consequences apply.
## 6. Consequences of Unacceptable Behavior
Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated.
Anyone asked to stop unacceptable behavior is expected to comply immediately.
If a community member engages in unacceptable behavior, we may take any action deemed appropriate, up to and including a temporary ban or permanent expulsion from the community without warning. Examples of sanctions which may be applied include but is not limited to:
* Verbal warnings.
* Written warnings.
* Temporary absence from participation.
* Long-term absence from participation.
* Being required to follow a conduct agreement that dictates the process of returning to the community.
## 7. Reporting Guidelines
If you are subject to or witness unacceptable behavior, or have any other concerns, please notify us as soon as possible by emailing info@mautic.org, or contacting a Mautic team member on the specific platform.
Processes for dealing with breaches of the Code of Conduct can be found [here][coc-breaches].
## 8. Addressing Grievances
Only permanent resolutions (such as bans) may be appealed. To appeal a decision, contact the Mautic team at info@mautic.org with your appeal and the team will review the situation.
## 9. Scope
We expect all community participants (contributors, moderators and other guests) to abide by this Code of Conduct in all community venuesonline and in-personas well as in all one-on-one communications pertaining to community affairs.
While this code of conduct is specifically aimed at Mautics official resources and community, we recognize that it is possible for actions taken outside of Mautics official online or in person spaces to have a deep impact on community health.
Resources or incidents which break this code of conduct for any reason in a non-Mautic community location will be considered in the same way as resources or incidents from owned channels, and subject to the same sanctions.
## 10. Contact info
For more information, please contact info@mautic.org.
## 11. License and attribution
This Code of Conduct is directly adapted from the Stumptown Syndicate and distributed under a [Creative Commons Attribution-ShareAlike license][cc-by-sa].
Additional text from [Mozilla Community Participation Guidelines][mozilla-guidelines] distributed under a [Creative Commons Attribution-ShareAlike license][cc-by-sa].
Reviewed and updated using the [Mozilla Code of Conduct Assessment Tool][mozilla-tool].
[coc-breaches]: </policies/code-of-conduct-breaches>
[mozilla-guidelines]: <https://www.mozilla.org/en-US/about/governance/policies/participation/>
[cc-by-sa]: <https://creativecommons.org/licenses/by-sa/3.0/>
[mozilla-tool]:<https://mozilla.github.io/diversity-coc-review.io>
(Code of Conduct is subject to change without notice).

View File

@@ -0,0 +1,66 @@
## Reporting Security Vulnerabilities
If you think that you have found a security vulnerability, please email security@mautic.com with as much detail as possible. The core team will review the vulnerability and if found applicable, will create the patch in a private repository. The vulnerability will be disclosed once the patch has been included into a release.
## Contributing Code
Development is open and available to any member of the Mautic community. All fixes and improvements are done through pull requests to the code. This code is open source and publicly available.
### Developer Documentation
Developer documentation is available at [https://devdocs.mautic.org](https://devdocs.mautic.org). To add additions or corrects to the documentation, submit Issues or Pull Requests against [https://github.com/mautic/developer-documentation-new](https://github.com/mautic/developer-documentation-new).
### Core Feature Development Procedures
Pull Requests with additional features should be created with the Mautic Core goals in consideration. Any features that are created for core that dont follow the overall goals may not be included.
In addition to following the general direction of the development goals, the pull request code must be well-formed following coding standards and guidelines. If you wish to target a specific release version number for the feature, its best to make the pull request early so any feedback from the core team can be implemented and adequate testing can be performed.
Features that are determined not to fit within the direction of the Mautic Core goals are more than welcome to be created as plugins instead.
### Code Contribution Requirements
#### Code Standards
Mautic follows [Symfony's coding standards](http://symfony.com/doc/current/contributing/code/standards.html) by implementing pre-commit git hook running [php-cs-fixer](https://github.com/friendsofphp/php-cs-fixer), which is installed and updated with `composer install`/`composer update`.
All code styling is handled automatically by the aforementioned git hook. In case if you setup git hook correctly (which is true if you ever run `composer install`/`composer update` before creating a pull request), you can format your code as you like - it will be converted to Mautic code style automatically.
#### Automated Tests
All code contributions should include adequate and appropriate unit tests using [PHPUnit](https://phpunit.de/manual/5.7/en/index.html) and/or Symfony functional tests ([https://symfony.com/doc/2.8/testing.html](https://symfony.com/doc/2.8/testing.html)). Pull Requests without these tests will not be merged.
#### Pull Request Description
When creating a new Pull Request, the description template should be filled appropriately in detail. Any Pull Request that does not have an appropriate description will not be considered for merge.
#### Documentation
Each new feature should include a reference to a pull request in our [End User Documentation](https://github.com/mautic/documentation) repository or [Developer Documentation](https://github.com/mautic/developer-documentation) repository if applicable.
## Core Development Rules
Pull requests and code submissions are decided upon by the release leader and the core team. When a decision is not clearly evident then the following voting process will be implemented.
### Voting Policy
Votes are cast by all members of the core team. Votes can be changed at any time during the discussion. Positive votes require no explanation. A negative vote must be justified by technical or objective logic. A core team member cannot vote on any code they submit.
### Merging Policy
The voting process on any particular pull request must allow for enough time for review by the community and the core team. This involves a minimum of 2 days for minor modifications and minimum of 5 days for significant code changes. Minor changes involve typographical errors, documentation, code standards, minor CSS, javascript, and HTML modifications. Minor modifications do not require a voting process. All other submissions require a vote after the minimum code review period and must be approved by two or more core members (with no core members voting against).
### Core Membership Application
Core team members are based on a form of meritocracy. We actively seek to empower our active community members and those demonstrating increased involvement will be given everything needed for their continued success.
### Core Membership Revocation
A Mautic Core membership can be revoked for any of the following reasons:
- Refusal to follow the rules and policies listed herein
- Lack of activity for the previous 6 months
- Willful negligence or intent to harm the Mautic project
- Upon decision of the project leader
Revoked members may re-apply for core membership following a 12 month period.

View File

@@ -0,0 +1,2 @@
github: mautic
open_collective: mautic

View File

@@ -0,0 +1,26 @@
# Workflow name:
name: Close Pull Requests
# Workflow triggers:
on:
pull_request_target:
types: [opened]
# Workflow jobs:
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thank you for submitting a pull request. :raised_hands:
We greatly appreciate your willingness to submit a contribution. However, we are not accepting pull requests against this repository, as all development happens on the [main project repository](https://github.com/mautic/mautic).
We kindly request that you submit this pull request against the [respective directory](https://github.com/mautic/mautic/blob/head/plugins/GrapesJsBuilderBundle) of the main repository where we'll review and provide feedback. If this is your first Mautic contribution, be sure to read the [contributing guide](https://github.com/mautic/mautic/blob/4.x/.github/CONTRIBUTING.md) which provides guidelines and instructions for submitting contributions.
Thank you again, and we look forward to receiving your contribution! :smiley:
Best,
The Mautic team

View File

@@ -0,0 +1,3 @@
vendor/*
node_modules/*
!.npmrc

View File

@@ -0,0 +1,2 @@
# .npmrc
engine-strict=true

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -0,0 +1 @@
.code-panel{text-align:left;font-size:1rem;height:100%;display:flex;flex-direction:column}.code-panel section{flex:1}.code-panel section .codepanel-separator{display:flex;justify-content:space-between;padding-left:.6rem;padding-right:.6rem}.code-panel section .codepanel-label{line-height:20px;font-size:13px;color:#aaa;user-select:none;text-transform:uppercase}.code-panel section button{background-color:#d6d6d6}.gutter{cursor:ns-resize;position:relative;background-color:rgba(0,0,0,0.2)}.gutter:after{content:'';display:block;height:8px;width:100%;position:absolute;top:-3px;z-index:150}.code-panel .CodeMirror{height:calc(100% - 20px)}.gjs-pn-views{border-left:1px solid rgba(0,0,0,0.2);border-bottom:0}.gjs-pn-views-container{box-shadow:initial;top:40px;padding-top:0;height:calc(100% - 40px)}.gjs-pn-views-container,.gjs-cv-canvas{transition:width .3s ease-in-out}

View File

@@ -0,0 +1,85 @@
export default class AssetService {
constructor() {
this.uploadPath = '/s/grapesjsbuilder/upload';
this.deletePath = '/s/grapesjsbuilder/delete';
this.assetsPath = '/s/grapesjsbuilder/media';
this.assetsPage = 1;
this.totalPages = null;
}
/**
* Get the path for uploading assets
* @returns {string} The upload path
*/
getUploadPath() {
return this.uploadPath;
}
/**
* Get the path for deleting assets
* @returns {string} The delete path
*/
getDeletePath() {
return this.deletePath;
};
/**
* Get assets path with current page
* @returns {string}
*/
getAssetsPath() {
return `${this.assetsPath}?page=${this.assetsPage}`;
}
/**
* Fetch assets from the server
* @param {string} assetsPath
* @returns {Promise<Object>}
*/
async fetchAssets(assetsPath) {
try {
const response = await fetch(assetsPath);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const result = await response.json();
if (result.totalPages !== undefined) {
this.totalPages = result.totalPages;
}
return result;
} catch (error) {
console.error('Error fetching assets:', error);
throw error;
}
}
/**
* Get assets for the first page
* @returns {Promise<Object>}
*/
async getAssetsXhr() {
this.assetsPage = 1;
return this.fetchAssets(this.getAssetsPath());
}
/**
* Get assets for the next page
* @returns {Promise<Object>|null}
*/
async getAssetsNextPageXhr() {
if (this.hasLoadedAllAssets()) {
return null;
}
this.assetsPage++;
return this.fetchAssets(this.getAssetsPath());
}
/**
* Check if all assets have been loaded
* @returns {boolean}
*/
hasLoadedAllAssets() {
return this.totalPages !== null && this.assetsPage >= this.totalPages;
}
}

View File

@@ -0,0 +1,190 @@
import AssetService from './asset.service';
import BuilderService from './builder.service';
// all css get combined into one builder.css and automatically loaded via js/parcel
import 'grapesjs/dist/css/grapes.min.css';
import './grapesjs-custom.css';
/**
* Launch builder
*
* @param formName
*/
function launchBuilderGrapesjs(formName) {
if (useBuilderForCodeMode() === false) {
return;
}
Mautic.showChangeThemeWarning = true;
// Prepare HTML
mQuery('html').css('font-size', '100%');
mQuery('body').css('overflow-y', 'hidden');
mQuery('.builder-panel').css('padding', 0);
mQuery('.builder-panel').css('display', 'block');
const $builder = mQuery('.builder');
$builder.addClass('builder-active').removeClass('hide');
const assetService = new AssetService();
const builder = new BuilderService(assetService);
// Initialize GrapesJS
builder.initGrapesJS(formName);
// trigger show event on DOM element
$builder.trigger('builder:show', [builder.editor])
// trigger show event on editor instance
builder.editor.trigger('show');
// Load and add assets
(async () => {
try {
const result = await assetService.getAssetsXhr();
builder.editor.AssetManager.add(result.data);
} catch (error) {
console.error('Error loading initial assets:', error);
}
})();
}
/**
* The user acknowledges the risk before editing an email or landing page created in Code Mode in the Builder
*/
function useBuilderForCodeMode() {
const theme = mQuery('.theme-selected').find('[data-theme]').attr('data-theme');
const isCodeMode = theme === 'mautic_code_mode';
if (isCodeMode) {
if (confirm(Mautic.translate('grapesjsbuilder.builder.warning.code_mode')) === false) {
return false;
}
}
return true;
}
/**
* Set theme's HTML
*
* @param theme
*/
function setThemeHtml(theme) {
BuilderService.setupButtonLoadingIndicator(true);
// Load template and fill field
mQuery.ajax({
url: mQuery('#builder_url').val(),
data: `template=${theme}`,
dataType: 'json',
success(response) {
const textareaHtml = mQuery('textarea.builder-html');
const textareaMjml = mQuery('textarea.builder-mjml');
textareaHtml.val(response.templateHtml);
if (typeof textareaMjml !== 'undefined') {
textareaMjml.val(response.templateMjml);
}
// If MJML template, generate HTML before save
if (!textareaHtml.val().length && textareaMjml.val().length) {
const assetService = new AssetService();
const builder = new BuilderService(assetService);
textareaHtml.val(builder.mjmlToHtml(response.templateMjml));
}
},
error(request, textStatus) {
console.log(`setThemeHtml - Request failed: ${textStatus}`);
},
complete() {
BuilderService.setupButtonLoadingIndicator(false);
},
});
}
/**
* The builder button to launch GrapesJS will be disabled when the code mode theme is selected
*
* @param theme
*/
function switchBuilderButton(theme) {
const builderButton = mQuery('.btn-builder');
const mEmailBuilderButton = mQuery('#emailform_buttons_builder_toolbar_mobile');
const mPageBuilderButton = mQuery('#page_buttons_builder_toolbar_mobile');
const isCodeMode = theme === 'mautic_code_mode';
builderButton.attr('disabled', isCodeMode);
if (isCodeMode) {
mPageBuilderButton.addClass('link-is-disabled');
mEmailBuilderButton.addClass('link-is-disabled');
mPageBuilderButton.parent().addClass('is-not-allowed');
mEmailBuilderButton.parent().addClass('is-not-allowed');
} else {
mPageBuilderButton.removeClass('link-is-disabled');
mEmailBuilderButton.removeClass('link-is-disabled');
mPageBuilderButton.parent().removeClass('is-not-allowed');
mEmailBuilderButton.parent().removeClass('is-not-allowed');
}
}
/**
* The textarea with the HTML source will be displayed if the code mode theme is selected
*
* @param theme
*/
function switchCustomHtml(theme) {
const customHtmlRow = mQuery('#custom-html-row');
const isPageMode = mQuery('[name="page"]').length !== 0;
const isCodeMode = theme === 'mautic_code_mode';
const advancedTab = isPageMode ? mQuery('#advanced-tab') : null;
if (isCodeMode === true) {
customHtmlRow.removeClass('hidden');
isPageMode && advancedTab.removeClass('hidden');
} else {
customHtmlRow.addClass('hidden');
isPageMode && advancedTab.addClass('hidden');
}
}
/**
* Initialize original Mautic theme selection with grapejs specific modifications
*/
function initSelectThemeGrapesjs(parentInitSelectTheme) {
function childInitSelectTheme(themeField) {
const builderUrl = mQuery('#builder_url');
let url;
switchBuilderButton(themeField.val());
switchCustomHtml(themeField.val());
// Replace Mautic URL by plugin URL
if (builderUrl.length) {
if (builderUrl.val().indexOf('pages') !== -1) {
url = builderUrl.val().replace('s/pages/builder', 's/grapesjsbuilder/page');
} else {
url = builderUrl.val().replace('s/emails/builder', 's/grapesjsbuilder/email');
}
builderUrl.val(url);
}
// Launch original Mautic.initSelectTheme function
parentInitSelectTheme(themeField);
mQuery('[data-theme]').click((event) => {
const target = mQuery(event.target);
const theme = target.closest('[data-theme]').attr('data-theme');
switchBuilderButton(theme);
switchCustomHtml(theme);
});
}
return childInitSelectTheme;
}
Mautic.launchBuilder = launchBuilderGrapesjs;
Mautic.initSelectTheme = initSelectThemeGrapesjs(Mautic.initSelectTheme);
Mautic.setThemeHtml = setThemeHtml;

View File

@@ -0,0 +1,550 @@
import grapesjs from 'grapesjs';
import grapesjsmjml from 'grapesjs-mjml';
import grapesjsnewsletter from 'grapesjs-preset-newsletter';
import grapesjswebpage from 'grapesjs-preset-webpage';
import grapesjsblocksbasic from 'grapesjs-blocks-basic';
import grapesjscomponentcountdown from 'grapesjs-component-countdown';
import grapesjsnavbar from 'grapesjs-navbar';
import grapesjscustomcode from 'grapesjs-custom-code';
import grapesjstouch from 'grapesjs-touch';
import grapesjstuiimageeditor from 'grapesjs-tui-image-editor';
import grapesjsstylebg from 'grapesjs-style-bg';
import grapesjspostcss from 'grapesjs-parser-postcss';
import grapesjsckeditor from './plugins/grapesjs.ckeditor';
import contentService from 'grapesjs-preset-mautic/dist/content.service';
import grapesjsmautic from 'grapesjs-preset-mautic';
import editorFontsService from 'grapesjs-preset-mautic/dist/editorFonts/editorFonts.service';
import StorageService from './storage.service';
// for local dev
// import contentService from '../../../../../../grapesjs-preset-mautic/src/content.service';
// import grapesjsmautic from '../../../../../../grapesjs-preset-mautic/src';
import CodeModeButton from './codeMode/codeMode.button';
import CompCopyPaste from './commands/compCopyPaste';
import MjmlService from 'grapesjs-preset-mautic/dist/mjml/mjml.service';
export default class BuilderService {
editor;
storageService;
assetService;
/**
* @param {AssetService} assetService
*/
constructor(assetService) {
this.assetService = assetService;
}
/**
* Initialize GrapesJsBuilder
*
* @param object
*/
setListeners() {
if (!this.editor) {
throw Error('No editor found');
}
// Why would we not want to keep the history?
//
// this.editor.on('load', () => {
// const um = this.editor.UndoManager;
// // Clear stack of undo/redo
// um.clear();
// });
const keymaps = this.editor.Keymaps;
let allKeymaps;
if (mauticEditorFonts) {
this.editor.on('load', () => editorFontsService.loadEditorFonts(this.editor));
}
this.editor.on('modal:open', () => {
// Save all keyboard shortcuts
allKeymaps = { ...keymaps.getAll() };
// Remove keyboard shortcuts to prevent launch behind popup
keymaps.removeAll();
});
this.editor.on('modal:close', () => {
// ReMap keyboard shortcuts on modal close
Object.keys(allKeymaps).map((objectKey) => {
const shortcut = allKeymaps[objectKey];
keymaps.add(shortcut.id, shortcut.keys, shortcut.handler);
return keymaps;
});
});
this.editor.on('asset:remove', (response) => {
// Delete file on server
mQuery.ajax({
url: this.assetService.getDeletePath(),
data: { filename: response.getFilename() },
});
});
this.editor.on('asset:upload:error', (error) => {
Mautic.setFlashes(Mautic.addErrorFlashMessage(error));
});
this.editor.on('asset:open', () => {
const editor = this.editor;
const assetsService = this.assetService;
const assetsContainer = document.querySelector('.gjs-am-assets');
const $assetsSpinner = document.createElement('div');
$assetsSpinner.className = 'gjs-assets-spinner';
$assetsSpinner.innerHTML = '<i class="ri-loader-3-line ri-spin"></i>';
if (assetsContainer) {
let isLoading = false;
const loadNextPage = async () => {
if (isLoading) return;
isLoading = true;
assetsContainer.appendChild($assetsSpinner);
try {
const result = await assetsService.getAssetsNextPageXhr();
if (result) {
const assetManager = editor.AssetManager;
const currentAssets = assetManager.getAll().models;
const newAssets = result.data;
// Combine current assets with new assets
const combinedAssets = [...currentAssets, ...newAssets];
// Reset the entire collection with combined assets
assetManager.getAll().reset(combinedAssets);
assetManager.render();
}
} catch (error) {
console.error('Error loading next page of assets:', error);
} finally {
isLoading = false;
}
};
assetsContainer.addEventListener('scroll', function() {
const hasScrolledToBottom = this.scrollTop + this.clientHeight >= this.scrollHeight - 5;
if (hasScrolledToBottom && !assetsService.hasLoadedAllAssets()) {
loadNextPage();
}
});
} else {
console.warn('Element with class "gjs-am-assets" not found');
}
});
const triggerBuilderHide = () => {
// trigger hide event on DOM element
mQuery('.builder').trigger('builder:hide', [this.editor]);
// trigger hide event on editor instance
this.editor.trigger('hide');
};
this.editor.on('run:mautic-editor-page-html-close', triggerBuilderHide);
this.editor.on('run:mautic-editor-email-html-close', triggerBuilderHide);
this.editor.on('run:mautic-editor-email-mjml-close', triggerBuilderHide);
// add offset to flashes container for better UI visibility when builder is on
this.editor.on('show', () => mQuery('#flashes').addClass('alert-offset'));
this.editor.on('hide', () => mQuery('#flashes').removeClass('alert-offset'));
}
/**
* Initialize the grapesjs build in the
* correct mode
*/
initGrapesJS(object) {
// grapesjs-custom-plugins: add globally defined mautic-grapesjs-plugins using name as pluginId for the plugin-function
if (window.MauticGrapesJsPlugins) {
window.MauticGrapesJsPlugins.forEach((item) => {
if (!item.name) {
console.warn('A name is required for Mautic-GrapesJs plugins in window.MauticGrapesJsPlugins. Registration skipped!');
return;
}
if (typeof item.plugin !== 'function') {
console.warn('The Mautic-GrapesJs plugin must be a function in window.MauticGrapesJsPlugins. Registration skipped!');
return;
}
grapesjs.plugins.add(item.name, item.plugin);
});
}
// disable mautic global shortcuts
Mousetrap.reset();
if (object === 'page') {
this.editor = this.initPage();
} else if (object === 'emailform') {
if (MjmlService.getOriginalContentMjml()) {
this.editor = this.initEmailMjml();
} else {
this.editor = this.initEmailHtml();
}
} else {
throw Error(`Not supported builder type: ${object}`);
}
// add code mode button
// @todo: only show button if configured: sourceEdit: 1,
const codeModeButton = new CodeModeButton(this.editor);
codeModeButton.addCommand();
codeModeButton.addButton();
/**
* Add command that will allow users
* to copy paste component across tabs
*/
new CompCopyPaste(this.editor).addCommand();
this.storageService = new StorageService(this.editor, object);
this.setListeners();
}
static getMauticConf(mode) {
return {
mode,
};
}
static getCkeConf(tokenCallback) {
const ckEditorToolbarOptions = ['undo', 'redo', '|', 'bold','italic', 'underline','strikethrough', '|', 'fontSize','fontFamily','fontColor','fontBackgroundColor', '|' ,'alignment','outdent', 'indent', '|', 'blockQuote', 'insertTable', '|', 'bulletedList','numberedList', '|', 'link', '|', 'TokenPlugin'];
return Mautic.GetCkEditorConfigOptions(ckEditorToolbarOptions, tokenCallback);
}
/**
* Initialize the builder in the landingapge mode
*/
initPage() {
// Launch GrapesJS with body part
this.editor = grapesjs.init({
clearOnRender: true,
container: '.builder-panel',
components: contentService.getOriginalContentHtml().body.innerHTML,
height: '100%',
canvas: {
styles: contentService.getStyles(),
},
storageManager: false, // https://grapesjs.com/docs/modules/Storage.html#basic-configuration
assetManager: this.getAssetManagerConf(),
styleManager: {
clearProperties: true, // Temp fix https://github.com/artf/grapesjs-preset-webpage/issues/27
},
plugins: [
// partially copied from: https://github.com/GrapesJS/grapesjs/blob/gh-pages/demo.html
grapesjswebpage,
grapesjspostcss,
grapesjsmautic,
grapesjsckeditor,
grapesjsblocksbasic,
grapesjscomponentcountdown,
grapesjsnavbar,
grapesjscustomcode,
grapesjstouch,
grapesjspostcss,
grapesjstuiimageeditor,
grapesjsstylebg,
...BuilderService.getPluginNames('page'), // grapesjs-custom-plugins: load custom plugins by their name
],
pluginsOpts: {
[grapesjswebpage]: {
formsOpts: false,
useCustomTheme: false,
},
grapesjsmautic: BuilderService.getMauticConf('page-html'),
[grapesjsckeditor]: BuilderService.getCkeConf('page:getBuilderTokens'),
...BuilderService.getPluginOptions('page'), // grapesjs-custom-plugins: add the plugin-options
},
});
this.moveBlocksPage();
return this.editor;
}
mjmlToHtml(mjml) {
const converted = MjmlService.mjmlToHtml(mjml);
if (0 === converted.errors.length) {
return converted.html;
}
return '';
}
initEmailMjml() {
const components = MjmlService.getOriginalContentMjml();
// validate
MjmlService.mjmlToHtml(components);
const styles = [
`${mauticBaseUrl}plugins/GrapesJsBuilderBundle/Assets/library/js/grapesjs-editor.css`
];
this.editor = grapesjs.init({
selectorManager: {
componentFirst: true,
},
avoidInlineStyle: false, // TEMP: fixes issue with disappearing inline styles
forceClass: false, // create new styles if there are some already on the element: https://github.com/GrapesJS/grapesjs/issues/1531
clearOnRender: true,
container: '.builder-panel',
height: '100%',
canvas: {
styles,
},
domComponents: {
// disable all except link components
disableTextInnerChilds: (child) => !child.is('link'), // https://github.com/GrapesJS/grapesjs/releases/tag/v0.21.2
},
storageManager: false,
assetManager: this.getAssetManagerConf(),
plugins: [grapesjsmjml, grapesjspostcss, grapesjsmautic, grapesjsckeditor, ...BuilderService.getPluginNames('email-mjml')],
pluginsOpts: {
[grapesjsmjml]: {
hideSelector: false,
custom: false,
useCustomTheme: false,
},
grapesjsmautic: BuilderService.getMauticConf('email-mjml'),
[grapesjsckeditor]: BuilderService.getCkeConf('email:getBuilderTokens'),
...BuilderService.getPluginOptions('email-mjml'),
},
});
this.unsetComponentVoidTypes(this.editor);
this.editor.setComponents(components);
// Reinitialize the content after parsing MJML.
// This can be removed once the issue with self-closing tags is resolved in grapesjs-mjml.
// See: https://github.com/GrapesJS/mjml/issues/149
const parsedContent = MjmlService.getEditorMjmlContent(this.editor);
this.editor.setComponents(parsedContent);
this.editor.BlockManager.get('mj-button').set({
content: '<mj-button href="https://">Button</mj-button>',
});
this.removeSelectedElementsEmailMjml();
return this.editor;
}
unsetComponentVoidTypes(editor) {
// Support for self-closing components is temporarily disabled due to parsing issues with mjml tags.
// Browsers only recognize explicit self-closing tags like <img /> and <br />, leading to rendering problems.
// This can be reverted once the issue with self-closing tags is resolved in grapesjs-mjml.
// See: https://github.com/GrapesJS/mjml/issues/149
const voidTypes = ['mj-image', 'mj-divider', 'mj-font', 'mj-spacer'];
voidTypes.forEach(function(component) {
editor.DomComponents.addType(component, {
model: {
defaults: {
void: false
},
toHTML() {
const tag = this.get('tagName');
const attr = this.getAttrToHTML();
const content = this.get('content');
let strAttr = '';
for (let prop in attr) {
const val = attr[prop];
const hasValue = typeof val !== 'undefined' && val !== '';
strAttr += hasValue ? ` ${prop}="${val}"` : '';
}
let html = `<${tag}${strAttr}>${content}</${tag}>`;
// Add the components after the closing tag
const componentsHtml = this.get('components')
.map(model => model.toHTML())
.join('');
return html + componentsHtml;
},
}
});
});
}
initEmailHtml() {
const components = contentService.getOriginalContentHtml().body.innerHTML;
if (!components) {
throw new Error('no components');
}
const styles = [
`${mauticBaseUrl}plugins/GrapesJsBuilderBundle/Assets/library/js/grapesjs-editor.css`
];
// Launch GrapesJS with body part
this.editor = grapesjs.init({
clearOnRender: true,
container: '.builder-panel',
components,
height: '100%',
canvas: {
styles,
},
storageManager: false,
assetManager: this.getAssetManagerConf(),
plugins: [grapesjsnewsletter, grapesjspostcss, grapesjsmautic, grapesjsckeditor, ...BuilderService.getPluginNames('email-html')],
pluginsOpts: {
grapesjsnewsletter: {
useCustomTheme: false,
},
grapesjsmautic: BuilderService.getMauticConf('email-html'),
[grapesjsckeditor]: BuilderService.getCkeConf('email:getBuilderTokens'),
...BuilderService.getPluginOptions('email-html'),
},
});
// add a Mautic custom block Button
this.editor.BlockManager.get('button').set({
content:
'<a href="#" target="_blank" style="display:inline-block;text-decoration:none;border-color:#4e5d9d;border-width: 10px 20px;border-style:solid; text-decoration: none; -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; background-color: #4e5d9d; display: inline-block;font-size: 16px; color: #ffffff; ">\n' +
'Button\n' +
'</a>',
});
return this.editor;
}
/**
* Return the names of dynamically added plugins
* @param context
* @returns string[]
*/
static getPluginNames(context) {
let plugins = [];
if (window.MauticGrapesJsPlugins) {
window.MauticGrapesJsPlugins.forEach((item) => {
if (item.name) {
if (!item.context || !Array.isArray(item.context) || item.context.length === 0) {
// if no context is given, the plugin is always added
plugins.push(item.name);
} else {
// check if the plugin should be added for the current editor context
item.context.forEach((pluginContext) => {
if (pluginContext === context) {
plugins.push(item.name);
}
})
}
}
});
}
return plugins;
}
/**
* Return the options of dynamically added plugins
* @param context
* @returns object[]
*/
static getPluginOptions(context) {
let pluginOptions = {};
if (window.MauticGrapesJsPlugins) {
window.MauticGrapesJsPlugins.forEach((item) => {
if (!item.context || !Array.isArray(item.context) || item.context.length === 0) {
// if no context is given, the plugin is always added
pluginOptions[item.name] = item.pluginOptions ?? {};
} else {
// check if the plugin should be added for the current editor context
item.context.forEach((pluginContext) => {
if (pluginContext === context) {
pluginOptions[item.name] = item.pluginOptions ?? {};
}
})
}
});
}
return pluginOptions;
}
/**
* Manage button loading indicator
*
* @param activate - true or false
*/
static setupButtonLoadingIndicator(activate) {
const builderButton = mQuery('.btn-builder');
const saveButton = mQuery('.btn-save');
const applyButton = mQuery('.btn-apply');
if (activate) {
Mautic.activateButtonLoadingIndicator(builderButton);
Mautic.activateButtonLoadingIndicator(saveButton);
Mautic.activateButtonLoadingIndicator(applyButton);
} else {
Mautic.removeButtonLoadingIndicator(builderButton);
Mautic.removeButtonLoadingIndicator(saveButton);
Mautic.removeButtonLoadingIndicator(applyButton);
}
}
/**
* Configure the Asset Manager for all modes
* @link https://grapesjs.com/docs/modules/Assets.html#configuration
*/
getAssetManagerConf() {
return {
assets: [],
noAssets: Mautic.translate('grapesjsbuilder.assetManager.noAssets'),
upload: this.assetService.getUploadPath(),
uploadName: 'files',
multiUpload: 1,
embedAsBase64: false,
openAssetsOnDrop: 1,
autoAdd: 1,
headers: { 'X-CSRF-Token': mauticAjaxCsrf }, // global variable
};
}
getEditor() {
return this.editor;
}
/**
* Move the blocks and categories in the sidebar
*/
moveBlocksPage() {
const blocks = this.editor.BlockManager.getAll();
blocks.map(block => {
// columns go into a new category, at the top
if(block.attributes.id.indexOf('column') !== -1) {
this.editor.BlockManager.get(block.attributes.id).set('category', {
label:"Sections",
order: -1
});
}
// 'Blocks' category goes after 'Basic'
if(block.attributes.category === 'Basic') {
this.editor.BlockManager.get(block.attributes.id).set('category', {
label:"Basic",
order: -1
});
}
});
}
removeSelectedElementsEmailMjml() {
// Remove the RAW block (it's just not usable)
const rawblock = this.editor.BlockManager.get('mj-raw');
if (rawblock !== null) {
this.editor.BlockManager.remove(rawblock);
}
}
}

View File

@@ -0,0 +1,129 @@
// import ContentService from '../../../../../../../grapesjs-preset-mautic/src/content.service';
import MjmlService from 'grapesjs-preset-mautic/dist/mjml/mjml.service';
import ContentService from 'grapesjs-preset-mautic/dist/content.service';
class CodeEditor {
editor;
opts;
codeEditor;
codePopup;
constructor(editor, opts = {}) {
this.editor = editor;
this.opts = opts;
this.codeEditor = this.buildCodeEditor();
this.codePopup = this.buildCodePopup();
}
// Build codeEditor (CodeMirror instance)
buildCodeEditor() {
const codeEditor = this.editor.CodeManager.getViewer('CodeMirror').clone();
codeEditor.set({
codeName: 'htmlmixed',
readOnly: false,
theme: 'hopscotch',
autoBeautify: true,
autoCloseTags: true,
autoCloseBrackets: true,
lineWrapping: true,
styleActiveLine: true,
smartIndent: true,
indentWithTabs: true,
});
return codeEditor;
}
// Build popup content, codeEditor area and buttons
buildCodePopup() {
const cfg = this.editor.getConfig();
const codePopup = document.createElement('div');
const btnEdit = document.createElement('button');
const btnCancel = document.createElement('button');
const textarea = document.createElement('textarea');
btnEdit.innerHTML = Mautic.translate('grapesjsbuilder.sourceEditBtnLabel');
btnEdit.className = `${cfg.stylePrefix}btn-prim ${cfg.stylePrefix}btn-code-edit`;
btnEdit.onclick = this.updateCode.bind(this);
btnCancel.innerHTML = Mautic.translate('grapesjsbuilder.sourceCancelBtnLabel');
btnCancel.className = `${cfg.stylePrefix}btn-prim ${cfg.stylePrefix}btn-code-cancel`;
btnCancel.onclick = this.cancelCode.bind(this);
codePopup.appendChild(textarea);
codePopup.appendChild(btnEdit);
codePopup.appendChild(btnCancel);
this.codeEditor.init(textarea);
return codePopup;
}
// Load content and show popup
showCodePopup(editor) {
this.updateEditorContents();
// this.codeEditor.editor.refresh();
// editor.Modal.setContent('');
editor.Modal.setContent(this.codePopup);
editor.Modal.setTitle(Mautic.translate('grapesjsbuilder.sourceEditModalTitle'));
editor.Modal.open();
editor.Modal.onceClose(() => editor.stopCommand('preset-mautic:code-edit'));
}
/**
* Update the main editors canvas content with the
* content from modals editor.
* @todo show validation results in UI
*/
updateCode() {
const code = this.codeEditor.editor.getValue();
// validate MJML code
if (ContentService.isMjmlMode(this.editor)) {
MjmlService.mjmlToHtml(code);
}
try {
// delete canvas and set new content
this.editor.DomComponents.getWrapper().set('content', '');
this.editor.setComponents(code.trim())
// Reinitialize the content after parsing MJML.
// This can be removed once the issue with self-closing tags is resolved in grapesjs-mjml.
// See: https://github.com/GrapesJS/mjml/issues/149
const parsedContent = MjmlService.getEditorMjmlContent(this.editor);
this.editor.setComponents(parsedContent);
this.editor.Modal.close();
} catch (e) {
window.alert(`${Mautic.translate('grapesjsbuilder.sourceSyntaxError')} \n${e.message}`);
}
}
// Close popup
cancelCode() {
this.editor.Modal.close();
}
/**
* Set the content to be edited in the popup editor
*/
updateEditorContents() {
// Check if MJML plugin is on
let content;
if (ContentService.isMjmlMode(this.editor)) {
content = MjmlService.getEditorMjmlContent(this.editor);
} else {
content = ContentService.getEditorHtmlContent(this.editor);
}
this.codeEditor.setContent(content);
}
}
export default CodeEditor;

View File

@@ -0,0 +1,35 @@
import CodeModeCommand from './codeMode.command';
export default class CodeModeButton {
editor;
/**
* Add close button with save for Mautic
*/
constructor(editor) {
if (!editor) {
throw new Error('no editor');
}
this.editor = editor;
}
addButton() {
this.editor.Panels.addButton('options', [
{
id: 'code-edit',
className: 'ri-edit-line',
attributes: {
title: Mautic.translate('grapesjsbuilder.sourceEditModalTitle'),
},
command: CodeModeCommand.name,
},
]);
}
addCommand() {
this.editor.Commands.add(CodeModeCommand.name, {
run: CodeModeCommand.launchCodeEditorModal,
stop: CodeModeCommand.stopCodeEditorModal,
});
}
}

View File

@@ -0,0 +1,35 @@
import CodeEditor from './codeEditor';
export default class CodeModeCommand {
/**
* The command to run on button click
*/
static name = 'preset-mautic:code-edit';
static codeEditor;
static launchCodeEditorModal(editor, sender, opts) {
if (!editor) {
throw new Error('no editor');
}
CodeModeCommand.codeEditor = new CodeEditor(editor, opts);
if (sender) {
sender.set('active', 0);
}
CodeModeCommand.codeEditor.showCodePopup(editor);
// Transform DC Component to token
editor.runCommand('preset-mautic:dynamic-content-components-to-tokens');
}
static stopCodeEditorModal(editor) {
if (!editor) {
throw new Error('no editor');
}
// Transform Token to Components
editor.runCommand('preset-mautic:update-dc-components-from-dc-store');
}
}

View File

@@ -0,0 +1,91 @@
// copied from: https://github.com/GrapesJS/grapesjs/issues/1855
export default class CompCopyPaste {
static storage_key = 'preset-mautic:grapesjs-clipboard';
editor;
// COPY PASTE COMPONENTS/STYLE BETWEEN PAGES
getStyles(components) {
// recurse down through components and store styles in temp attribute
components.forEach((component) => {
const recurse = (comp) => {
// if component has any styling
if (Object.keys(comp.getStyle()).length !== 0) comp.attributes.savedStyle = comp.getStyle();
if (comp.get('components').length) {
comp.get('components').forEach((child) => {
recurse(child);
});
}
};
recurse(component);
});
return components;
}
setStyles(component) {
// recurse down and re-apply style back to components
const recurse = (comp) => {
if ('savedStyle' in comp.attributes) {
comp.setStyle(comp.attributes.savedStyle);
delete comp.attributes.savedStyle;
}
if (comp.attributes.components.length) {
comp.attributes.components.forEach((child) => {
recurse(child);
});
}
};
recurse(component);
}
newCopy(selected) {
window.localStorage.setItem(this.storage_key, JSON.stringify(selected));
}
newPaste(selected) {
let components = JSON.parse(window.localStorage.getItem(this.storage_key));
if (components) {
if (selected && selected.attributes.type !== 'wrapper') {
const index = selected.index();
// Invert the order so last item gets added first and gets pushed down as others get added.
components.reverse();
const currentSelection = selected.collection;
components.forEach((comp) => {
if (currentSelection) {
const added = currentSelection.add(comp, { at: index + 1 });
editor.trigger('component:paste', added);
this.setStyles(added);
}
});
selected.emitUpdate();
} else {
components = editor.addComponents(components);
components.forEach((comp) => {
this.setStyles(comp);
});
}
}
}
constructor(editor) {
if (!editor) {
throw new Error('no editor');
}
this.editor = editor;
}
addCommand() {
this.editor.Commands.add('core:copy', (ed) => {
const selected = this.getStyles([...ed.getSelectedAll()]);
let filteredSelected = selected.filter((item) => item.attributes.copyable == true);
if (filteredSelected.length) {
this.newCopy(filteredSelected);
}
});
this.editor.Commands.add('core:paste', (ed) => {
const selected = ed.getSelected();
this.newPaste(selected);
});
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,375 @@
body,
html {
margin: 0;
height: 100%;
}
/* GrapesJS temporary fix #2490 */
.gjs-clm-tag-status,
.gjs-clm-tag-close {
}
.gjs-clm-tags-btn {
width: 24px;
}
/* Fix hidden scroll */
.gjs-pn-views-container {
height: auto;
padding: 0 0 0;
top: 40px;
bottom: 0;
background: #f0f0f0;
}
.gjs-btn-code-edit {
margin-top: 10px;
}
/* GrapesJS FileManager custom CSS */
.gjs-btn-code-cancel {
margin-left: 10px;
}
.gjs-am-file-uploader {
width: 100%;
float: none;
}
.gjs-am-assets-cont {
width: 100%;
float: none;
}
.gjs-am-assets {
height: 280px;
}
.gjs-am-asset {
width: 25%;
}
.gjs-am-file-uploader > form #gjs-am-uploadFile {
padding: 20px 10px;
}
.gjs-am-file-uploader #gjs-am-title {
padding: 20px 10px;
}
.gjs-am-preview {
background-size: contain;
}
.gjs-am-preview-cont {
width: 40%;
}
.gjs-am-meta {
width: 60%;
}
.gjs-pn-commands .gjs-pn-buttons {
justify-content: center;
}
/* GrapesJS Colors / Theme */
.gjs-clm-tags .gjs-sm-title,
.gjs-sm-sector .gjs-sm-title {
}
.gjs-block-category .gjs-title,
.gjs-category-title,
.gjs-clm-tags .gjs-sm-title,
.gjs-layer-title,
.gjs-sm-sector .gjs-sm-title {
font-weight: lighter;
background-color: rgba(0,0,0,.1);
letter-spacing: 1px;
padding: 9px 10px 9px 20px;
border-bottom: 1px solid rgba(0,0,0,.25);
text-align: left;
position: relative;
cursor: pointer;
}
.gjs-sm-properties {
font-size: .75rem;
padding: 10px 5px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
box-sizing: border-box;
width: 100%;
}
.gjs-clm-tags .gjs-clm-tag {
border: 1px solid #707070;
box-shadow: none;
text-shadow: none;
}
.gjs-field {
box-shadow: none;
}
.gjs-btnt.gjs-pn-active,
.gjs-pn-btn.gjs-pn-active {
box-shadow: none;
}
.gjs-import-label,
.gjs-export-label {
margin-bottom: 10px;
font-size: 13px;
}
.gjs-mdl-dialog .gjs-btn-import {
margin-top: 10px;
}
.CodeMirror {
border-radius: 3px;
height: 450px;
font-family: sans-serif, monospace;
letter-spacing: 0.3px;
font-size: 12px;
}
/* GrapesJS Extra */
#gjs-pn-views-container.gjs-pn-panel {
padding: 39px 0 0;
}
#gjs-pn-views.gjs-pn-panel {
padding: 0;
border: none;
}
#gjs-pn-views .gjs-pn-btn {
margin: 0;
height: 40px;
padding: 10px;
width: 25%;
border-bottom: 2px solid rgba(0, 0, 0, 0.3);
}
#gjs-pn-views .gjs-pn-active {
border-radius: 0;
}
#gjs-pn-devices-c {
padding-left: 30px;
}
#gjs-pn-options {
padding-right: 30px;
}
.gjs-sm-composite .gjs-sm-properties {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
}
#gjs-sm-border-top-left-radius,
#gjs-sm-border-top-right-radius,
#gjs-sm-border-bottom-left-radius,
#gjs-sm-border-bottom-right-radius,
#gjs-sm-margin-top,
#gjs-sm-margin-bottom,
#gjs-sm-margin-right,
#gjs-sm-margin-left,
#gjs-sm-padding-top,
#gjs-sm-padding-bottom,
#gjs-sm-padding-right,
#gjs-sm-padding-left {
}
#gjs-sm-border-width,
#gjs-sm-border-style,
#gjs-sm-border-color {
flex: 999 1 80px;
}
#gjs-sm-margin-left,
#gjs-sm-padding-left {
order: 2;
}
#gjs-sm-margin-right,
#gjs-sm-padding-right {
order: 3;
}
#gjs-sm-margin-bottom,
#gjs-sm-padding-bottom {
order: 4;
}
.gjs-field-radio {
width: 100%;
}
.gjs-field-radio #gjs-sm-input-holder {
display: flex;
}
.gjs-radio-item {
flex: 1 0 auto;
text-align: center;
}
.gjs-sm-sector .gjs-sm-property.gjs-sm-list {
width: 50%;
}
.gjs-mdl-content {
border-top: none;
}
.gjs-sm-sector .gjs-sm-property .gjs-sm-layer.gjs-sm-active {
background-color: rgba(255, 255, 255, 0.09);
}
.gjs-f-divider::before {
content: 'D';
}
.gjs-mdl-dialog-sm {
width: 300px;
}
.gjs-mdl-dialog form .gjs-sm-property {
font-size: 12px;
margin-bottom: 15px;
}
.gjs-mdl-dialog form .gjs-sm-label {
margin-bottom: 5px;
}
#gjs-clm-status-c {
display: none;
}
.anim-spin {
animation: 0.5s linear 0s normal none infinite running spin;
}
.form-status {
float: right;
font-size: 14px;
}
.text-danger {
color: #f92929;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.is-not-allowed {
cursor: not-allowed;
}
.link-is-disabled {
pointer-events: none;
text-decoration: none;
display: inline-block;
opacity: 0.5;
}
/* overrides for the icons now that they are SVGs */
.gjs-pn-panel .gjs-block__media svg {
max-height: 48px;
}
/* Newsletter preset theme */
.gjs-one-bg {
background-color: #373d49;
}
.gjs-one-color {
color: #373d49;
}
.gjs-one-color-h:hover {
color: #373d49;
}
.gjs-two-bg{
color: #dae5e6;
}
.gjs-two-color {
color: #dae5e6;
}
.gjs-two-color-h:hover {
color: #dae5e6;
}
.gjs-three-bg {
background-color: #4c9790;
}
.gjs-three-color {
color: #4c9790;
}
.gjs-three-color-h:hover {
color: #4c9790;
}
.gjs-four-bg {
background-color: #35d7bb;
}
.gjs-four-color {
color: #35d7bb;
}
.gjs-four-color-h:hover {
color: #35d7bb;
}
/* revert colors for nested panels */
.gjs-mdl-dialog .panel {
color: var(--gjs-main-color);
}
.gjs-assets-spinner {
width: 100%;
text-align: center;
font-size: 26px;
}
.anim-spin {
animation: 0.5s linear 0s normal none infinite running spin; }
@keyframes spin {
0% {
transform: rotate(0deg); }
100% {
transform: rotate(360deg); } }
/* CKEditor styles */
.cke-modal .ck-editor .ck-content {
min-height: 400px;
background: var(--background);
color: var(--text-primary);
}
.cke-modal .gjs-btn-prim {
margin: 10px 5px 5px 0;
}

View File

@@ -0,0 +1,5 @@
html,
body,
[data-gjs-type='wrapper'] {
height: auto !important;
}

View File

@@ -0,0 +1,129 @@
export default (editor, opts = {}) => {
let ckEditorInstance = null;
const SIMPLE_EDITING_TYPES = ['mj-button', 'link'];
editor.on('rte:enable', (view, gjsRte) => {
if (!isSimpleEditingEl(view.el)) {
openCKEditorModal(gjsRte.el, view);
editor.RichTextEditor.hideToolbar();
}
});
function isSimpleEditingEl(el) {
return el && el.getAttribute && SIMPLE_EDITING_TYPES.includes(el.getAttribute('data-gjs-type'));
}
function openCKEditorModal(el, view) {
const ckEditorElementId = `ckeditor-${Date.now()}`;
const modal = editor.Modal;
modal.onceOpen(() => initCKEditor(ckEditorElementId));
modal.onceClose(() => destroyCKEditor());
modal.config.backdrop = false;
modal.open({
title: 'Edit',
content: `
<div id="${ckEditorElementId}">${el.innerHTML}</div>
<button type="button" class="gjs-btn-prim" id="gjs-cke-save-btn">Save</button>
<button type="button" class="gjs-btn-prim" id="gjs-cke-close-btn">Cancel</button>
`,
attributes: {
class: 'cke-modal'
}
});
const { backgroundColor, color } = getRealColors(el);
setEditorStyle(color, backgroundColor);
document.getElementById('gjs-cke-save-btn').onclick = () => saveContent(view, modal);
document.getElementById('gjs-cke-close-btn').onclick = () => modal.close();
}
function initCKEditor(elementId) {
if (typeof ClassicEditor === 'undefined') {
throw new Error('CKEDITOR instance not found');
}
ClassicEditor.create(document.getElementById(elementId), opts)
.then(instance => {
ckEditorInstance = instance;
})
.catch(error => {
console.error('Error initializing CKEditor:', error);
});
}
function destroyCKEditor() {
if (ckEditorInstance) {
ckEditorInstance.destroy()
.catch(error => {
console.error('Error destroying CKEditor instance:', error);
});
}
}
function saveContent(view, modal) {
if (ckEditorInstance) {
const content = ckEditorInstance.getData();
const selectedElement = view.model;
const currentContent = selectedElement.get('content');
if (currentContent !== content) {
// Clear existing components to avoid conflicts
selectedElement.components('');
// Set the new content
selectedElement.set('content', content);
}
}
modal.close();
}
function setEditorStyle(color, backgroundColor) {
const STYLE_ID = 'gjs-ckeditor-styles';
let styleElement = document.getElementById(STYLE_ID);
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = STYLE_ID;
document.head.appendChild(styleElement);
}
styleElement.innerHTML = `
.cke-modal .ck-editor .ck-content {
background: ${backgroundColor};
color: ${color};
}
`;
}
function getRealColors(elem, maxDepth = 100) {
const transparent = ['rgba(0, 0, 0, 0)', 'transparent'];
const defaults = { backgroundColor: 'rgba(0, 0, 0, 0)', color: 'rgb(0, 0, 0)' };
function getColors(el, depth) {
if (!el || depth <= 0) return defaults;
try {
const style = getComputedStyle(el);
const bg = style.backgroundColor;
const color = style.color;
const result = {};
if (!transparent.includes(bg)) result.backgroundColor = bg;
if (color && !transparent.includes(color)) result.color = color;
if (result.backgroundColor && result.color) return result;
const parentColors = getColors(el.parentElement, depth - 1);
return {
backgroundColor: result.backgroundColor || parentColors.backgroundColor,
color: result.color || parentColors.color
};
} catch (error) {
console.warn('Error computing colors:', error);
return defaults;
}
}
return getColors(elem, maxDepth);
}
};

View File

@@ -0,0 +1,182 @@
import MjmlService from "grapesjs-preset-mautic/dist/mjml/mjml.service";
import ContentService from 'grapesjs-preset-mautic/dist/content.service';
export default class StorageService {
constructor(editor, mode) {
this.editor = editor;
this.mode = mode;
this.maxStorageItems = 10;
this.init();
}
init() {
this.storageKey = 'gjs-storage';
this.restoreMessage = null;
const stackItemId = this.getStackItemId();
const storageItem = this.getStorageItemById(stackItemId);
const editorContent = this.getEditorContent();
if (storageItem && editorContent !== storageItem.content) {
this.displayRestoreMessage(storageItem);
}
this.editor.on("update", () => this.handleUpdate());
this.addFormSubmitListeners();
}
displayRestoreMessage(storedContent) {
const buttonContainer = document.createElement('div');
buttonContainer.className = 'alert-growl-buttons';
const restoreButton = document.createElement('button');
restoreButton.innerHTML = '<i class="ri-arrow-go-back-line"></i> ' + Mautic.translate('mautic.core.builder.storage.restore.button')
restoreButton.className = 'btn btn-primary btn-sm ml-md';
const dismissButton = document.createElement('button');
dismissButton.innerHTML = Mautic.translate('mautic.core.builder.storage.dismiss.button')
dismissButton.className = 'btn btn-ghost btn-sm';
buttonContainer.append(restoreButton, dismissButton);
const formattedDateTime = this.formatDateTime(storedContent.date);
const message = Mautic.translate('mautic.core.builder.storage.restore.message', {
date: formattedDateTime
});
const flashMessage = Mautic.addInfoFlashMessage(message);
flashMessage.append(buttonContainer);
const closeButton = flashMessage.querySelector('button.close')
this.addMessageEventListeners(restoreButton, dismissButton, closeButton);
Mautic.setFlashes(flashMessage, false);
this.restoreMessage = flashMessage;
}
dismissRestoreMessage() {
if (this.restoreMessage instanceof Element) {
this.restoreMessage.remove();
}
this.restoreMessage = null;
}
addMessageEventListeners(restoreButton, dismissButtom, closeButton) {
restoreButton.addEventListener('click', (event) => {
this.load();
this.dismissRestoreMessage();
event.preventDefault();
});
dismissButtom.addEventListener('click', (event) => {
this.handleUpdate();
this.dismissRestoreMessage();
this.removeStorageItemById(this.getStackItemId());
event.preventDefault();
});
closeButton.addEventListener('click', () => {
this.handleUpdate();
});
this.editor.on('hide', () => this.dismissRestoreMessage());
}
addFormSubmitListeners() {
mQuery(this.getForm()).on('submit:success', (e, requestUrl, response) => {
const lastRequestUrlPart = requestUrl.split('/').pop();
const lastResponseUrlPart = response.route.split('/').pop();
// Check if the form was submitted for a new entity and the response contains the entity id
// The success response code alone does not guarantee that the form was saved,
// so we need to validate the URL changes and the presence of the entity id
if (lastRequestUrlPart === 'new' && !isNaN(lastResponseUrlPart)){
// Remove the local storage item for the newly created entity after successful form submission
this.removeStorageItemById(`gjs-${this.mode}-${Mautic.builderTheme}-new`);
}
});
}
handleUpdate() {
// update the storage content only when the restore prompt is not available
if (!this.restoreMessage) {
const editorContent = this.getEditorContent();
const dateTime = new Date().toISOString();
const stackItemId = this.getStackItemId();
const contentWithDateTime = { id: stackItemId, content: editorContent, date: dateTime };
this.saveStorageItem(contentWithDateTime);
}
}
load() {
const stackItemId = this.getStackItemId();
const storageItem = this.getStorageItemById(stackItemId);
if (storageItem) {
this.editor.setComponents(storageItem.content);
}
}
getEditorContent() {
let content;
if (ContentService.isMjmlMode(this.editor)) {
content = MjmlService.getEditorMjmlContent(this.editor);
} else {
content = ContentService.getEditorHtmlContent(this.editor);
}
return content;
}
getStackItemId() {
const entityId = this.getFormEntityId(this.mode === 'page' ? 'page' : 'emailform')
return `gjs-${this.mode}-${Mautic.builderTheme}-${entityId}`;
}
saveStorageItem(item) {
const stack = JSON.parse(localStorage.getItem(this.storageKey)) || [];
const index = stack.findIndex(existingItem => existingItem.id === item.id);
if (index !== -1) {
// If the item already exists, update it
stack[index] = item;
} else {
// If the item doesn't exist, push it to the stack
if (stack.length >= this.maxStorageItems) {
// Ensure that the stack does not exceed the maximum allowed number of items
// to prevent web storage from exceeding its 10MiB per domain limit
stack.pop(); // Remove the oldest item
}
stack.push(item);
}
stack.sort((a, b) => new Date(b.date) - new Date(a.date));
localStorage.setItem(this.storageKey, JSON.stringify(stack));
}
getStorageItemById(id) {
const stack = JSON.parse(localStorage.getItem(this.storageKey)) || [];
return stack.find(item => item.id === id);
}
removeStorageItemById(id) {
const stack = JSON.parse(localStorage.getItem(this.storageKey)) || [];
const index = stack.findIndex(item => item.id === id);
if (index !== -1) {
stack.splice(index, 1);
localStorage.setItem(this.storageKey, JSON.stringify(stack));
}
}
formatDateTime(dateTime) {
return new Date(dateTime).toISOString().slice(0, 16).replace('T', ' ');
}
getFormEntityId(name) {
const form = document.querySelector(`form[name="${name}"]`);
const actionUrl = form.getAttribute('action');
const urlParts = actionUrl.split('/');
const lastPart = urlParts.pop();
if (isNaN(lastPart)) {
return 'new';
} else {
return lastPart;
}
}
getForm() {
return document.querySelector(this.mode === 'page' ? 'form[name="page"]' : 'form[name="emailform"]')
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
return [
'name' => 'GrapesJS Builder',
'description' => 'GrapesJS Builder with MJML support for Mautic',
'version' => '1.0.0',
'author' => 'Mautic Community',
'routes' => [
'main' => [
'grapesjsbuilder_upload' => [
'path' => '/grapesjsbuilder/upload',
'controller' => 'MauticPlugin\GrapesJsBuilderBundle\Controller\FileManagerController::uploadAction',
],
'grapesjsbuilder_delete' => [
'path' => '/grapesjsbuilder/delete',
'controller' => 'MauticPlugin\GrapesJsBuilderBundle\Controller\FileManagerController::deleteAction',
],
/** @depreacated since Mautic 5.2, to be removed in 6.0. Use grapesjsbuilder_media instead */
'grapesjsbuilder_assets' => [
'path' => '/grapesjsbuilder/assets',
'controller' => 'MauticPlugin\GrapesJsBuilderBundle\Controller\FileManagerController::assetsAction',
],
'grapesjsbuilder_media' => [
'path' => '/grapesjsbuilder/media',
'controller' => 'MauticPlugin\GrapesJsBuilderBundle\Controller\FileManagerController::getMediaAction',
],
'grapesjsbuilder_builder' => [
'path' => '/grapesjsbuilder/{objectType}/{objectId}',
'controller' => 'MauticPlugin\GrapesJsBuilderBundle\Controller\GrapesJsController::builderAction',
],
],
'public' => [],
'api' => [],
],
'menu' => [],
'services' => [
'other' => [
// Provides access to configured API keys, settings, field mapping, etc
'grapesjsbuilder.config' => [
'class' => MauticPlugin\GrapesJsBuilderBundle\Integration\Config::class,
'arguments' => [
'mautic.integrations.helper',
],
],
],
'sync' => [],
'helpers' => [
'grapesjsbuilder.helper.filemanager' => [
'class' => MauticPlugin\GrapesJsBuilderBundle\Helper\FileManager::class,
'arguments' => [
'mautic.helper.file_uploader',
'mautic.helper.core_parameters',
'mautic.helper.paths',
],
],
],
],
'parameters' => [
'image_path_exclude' => ['flags', 'mejs'], // exclude certain folders from showing in the image browser
'static_url' => '', // optional base url for images
],
];

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
'node_modules',
];
$services->load('MauticPlugin\\GrapesJsBuilderBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
$services->load('MauticPlugin\\GrapesJsBuilderBundle\\Entity\\', '../Entity/*Repository.php');
$services->alias('grapesjsbuilder.model', MauticPlugin\GrapesJsBuilderBundle\Model\GrapesJsBuilderModel::class);
// Basic definitions with name, display name and icon
$services->alias('mautic.integration.grapesjsbuilder', MauticPlugin\GrapesJsBuilderBundle\Integration\GrapesJsBuilderIntegration::class);
// Provides the form types to use for the configuration UI
$services->alias('grapesjsbuilder.integration.configuration', MauticPlugin\GrapesJsBuilderBundle\Integration\Support\ConfigSupport::class);
// Tells Mautic what themes it should support when enabled
$services->alias('grapesjsbuilder.integration.builder', MauticPlugin\GrapesJsBuilderBundle\Integration\Support\BuilderSupport::class);
$services->get(MauticPlugin\GrapesJsBuilderBundle\InstallFixtures\ORM\GrapesJsData::class)
->tag(Doctrine\Bundle\FixturesBundle\DependencyInjection\CompilerPass\FixturesCompilerPass::FIXTURE_TAG);
};

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Controller;
use Mautic\CoreBundle\Controller\AjaxController;
use Mautic\CoreBundle\Exception\FileUploadException;
use MauticPlugin\GrapesJsBuilderBundle\Helper\FileManager;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class FileManagerController extends AjaxController
{
private const DEFAULT_PAGE = 1;
private const DEFAULT_LIMIT = 20;
public function uploadAction(Request $request, FileManager $fileManager): Response
{
try {
$response = $this->sendJsonResponse(['data'=> $fileManager->uploadFiles($request)]);
} catch (FileUploadException $error) {
return new Response($error->getMessage(), Response::HTTP_BAD_REQUEST);
}
return $response;
}
public function deleteAction(Request $request, FileManager $fileManager): JsonResponse
{
$fileName = basename($request->get('filename'));
$filePath = $fileManager->getCompleteFilePath($fileName);
if (!file_exists($filePath) || !exif_imagetype($filePath)) {
return $this->sendJsonResponse(['success'=> false]);
}
$fileManager->deleteFile($fileName);
return $this->sendJsonResponse(['success'=> true]);
}
/**
* @deprecated since Mautic 5.2, to be removed in 6.0. Use FileManagerController::getMediaAction instead
*/
public function assetsAction(FileManager $fileManager): JsonResponse
{
return $this->sendJsonResponse([
'data' => $fileManager->getImages(),
]);
}
public function getMediaAction(Request $request, FileManager $fileManager): JsonResponse
{
$page = $request->query->getInt('page', self::DEFAULT_PAGE);
$limit = $request->query->getInt('limit', self::DEFAULT_LIMIT);
return $this->sendJsonResponse($fileManager->getMediaFiles($page, $limit));
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Controller;
use Mautic\CoreBundle\Controller\CommonController;
use Mautic\CoreBundle\Helper\EmojiHelper;
use Mautic\CoreBundle\Helper\InputHelper;
use Mautic\CoreBundle\Helper\ThemeHelper;
use Mautic\EmailBundle\Entity\Email;
use Mautic\PageBundle\Entity\Page;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class GrapesJsController extends CommonController
{
public const OBJECT_TYPE = ['email', 'page'];
/**
* Activate the custom builder.
*
* @param string $objectType
* @param int $objectId
*
* @return Response
*/
public function builderAction(
Request $request,
LoggerInterface $mauticLogger,
ThemeHelper $themeHelper,
$objectType,
$objectId,
) {
if (!in_array($objectType, self::OBJECT_TYPE)) {
throw new \Exception('Object not authorized to load custom builder', Response::HTTP_CONFLICT);
}
/** @var \Mautic\EmailBundle\Model\EmailModel|\Mautic\PageBundle\Model\PageModel $model */
$model = $this->getModel($objectType);
$aclToCheck = 'email:emails:';
if ('page' === $objectType) {
$aclToCheck = 'page:pages:';
}
// permission check
if (str_contains((string) $objectId, 'new')) {
$isNew = true;
if (!$this->security->isGranted($aclToCheck.'create')) {
return $this->accessDenied();
}
/** @var Email|Page $entity */
$entity = $model->getEntity();
$entity->setSessionId($objectId);
} else {
/** @var Email|Page $entity */
$entity = $model->getEntity($objectId);
$isNew = false;
if (null == $entity
|| !$this->security->hasEntityAccess(
$aclToCheck.'viewown',
$aclToCheck.'viewother',
$entity->getCreatedBy()
)
) {
return $this->accessDenied();
}
}
$type = 'html';
$template = InputHelper::clean($request->query->get('template'));
if (!$template) {
$mauticLogger->warning('Grapesjs: no template in query');
return $this->json(false);
}
$templateName = '@themes/'.$template.'/html/'.$objectType;
$content = $entity->getContent();
// Check for MJML template
// @deprecated - use mjml directly in email.html.twig
if ($logicalName = $this->checkForMjmlTemplate($templateName.'.mjml.twig')) {
$type = 'mjml';
} else {
$logicalName = $themeHelper->checkForTwigTemplate($templateName.'.html.twig');
}
// Replace short codes to emoji
$content = array_map(fn ($text) => EmojiHelper::toEmoji($text, 'short'), $content);
$renderedTemplate = $this->renderView(
$logicalName,
[
'isNew' => $isNew,
'content' => $content,
$objectType => $entity,
'template' => $template,
'basePath' => $request->getBasePath(),
]
);
if (str_contains($renderedTemplate, '<mjml>')) {
$type = 'mjml';
}
$renderedTemplateHtml = ('html' === $type) ? $renderedTemplate : '';
$renderedTemplateMjml = ('mjml' === $type) ? $renderedTemplate : '';
return $this->render(
'@GrapesJsBuilder/Builder/template.html.twig',
[
'templateHtml' => $renderedTemplateHtml,
'templateMjml' => $renderedTemplateMjml,
]
);
}
/**
* @deprecated deprecated since version 5.0 - use mjml directly in email.html.twig
*/
private function checkForMjmlTemplate($template)
{
$twig = $this->container->get('twig');
if ($twig->getLoader()->exists($template)) {
return $template;
}
return null;
}
}

View File

@@ -0,0 +1,4 @@
{
"templateHtml": "",
"templateMjml": "<mjml>\n <mj-body>\n <mj-section>\n <mj-column>\n <mj-text font-size=\"20px\">Hello there!</mj-text>\n <mj-text>This is MJML version of blank template for Mautic.</mj-text>\n <mj-text>{unsubscribe_text} | {webview_text}</mj-text>\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>"
}

View File

@@ -0,0 +1,36 @@
import 'grapesjs/dist/css/grapes.min.css';
import grapesJS from 'grapesjs';
import grapesJSMJML from 'grapesjs-mjml';
const editor = grapesJS.init({
fromElement: 1,
container: '#gjs',
avoidInlineStyle: false,
plugins: [grapesJSMJML],
pluginsOpts: {
[grapesJSMJML]: {
// The font imports are included on HTML <head/> when fonts are used on the template
fonts: {
Montserrat: 'https://fonts.googleapis.com/css?family=Montserrat',
'Open Sans': 'https://fonts.googleapis.com/css?family=Open+Sans',
},
},
},
});
// add custom fonts options on editor's font list
editor.on('load', () => {
const styleManager = editor.StyleManager;
const fontProperty = styleManager.getProperty('typography', 'font-family');
const list = [];
// empty list
fontProperty.set('list', list);
// custom list
list.push(fontProperty.addOption({ value: 'Montserrat, sans-serif', name: 'Montserrat' }));
list.push(fontProperty.addOption({ value: 'Open Sans, sans-serif', name: 'Open Sans' }));
fontProperty.set('list', list);
styleManager.render();
});

View File

@@ -0,0 +1,36 @@
<html>
<head>
<title>Hello World</title>
<script src="https://cdn.ckeditor.com/4.16.0/standard-all/ckeditor.js"></script>
<link
rel="stylesheet"
href="../../node_modules/grapesjs/dist/css/grapes.min.css"
data-source="mautic"
/>
<link href="style.css" rel="stylesheet" />
</head>
<body>
<div id="gjs">
<mjml>
<mj-body>
<mj-raw>
<!-- Company Header -->
</mj-raw>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="24px" color="#6f6f6f">My Company </mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fafafa">
<mj-column width="400px">
<mj-image
src="https://via.placeholder.com/172x215/%7B%7Bgray600%7D%7D/ffffff?text=172+x+215+x2"
/>
</mj-column>
</mj-section>
</mj-body>
</mjml>
</div>
<script src="helloWorld.js"></script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
/* Let's highlight canvas boundaries */
#gjs {
border: 3px solid #444;
}
/* Reset some default styling */
.gjs-cv-canvas {
top: 0;
width: 100%;
height: 100%;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class GrapesJsBuilderExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Entity;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Mautic\CoreBundle\Doctrine\Mapping\ClassMetadataBuilder;
use Mautic\EmailBundle\Entity\Email;
class GrapesJsBuilder
{
/**
* @var int
*/
protected $id;
/**
* @var Email|null
*/
protected $email;
/**
* @var string|null
*/
private $customMjml;
private ?string $draftCustomMjml = null;
public static function loadMetadata(ORM\ClassMetadata $metadata): void
{
$builder = new ClassMetadataBuilder($metadata);
$builder->setTable('bundle_grapesjsbuilder')
->setCustomRepositoryClass(GrapesJsBuilderRepository::class)
->addNamedField('customMjml', Types::TEXT, 'custom_mjml', true)
->addNamedField('draftCustomMjml', Types::TEXT, 'draft_custom_mjml', true)
->addId();
$builder->createManyToOne(
'email',
Email::class
)->addJoinColumn('email_id', 'id', true, false, 'CASCADE')->build();
}
/**
* Get id.
*
* @return int
*/
public function getId()
{
return $this->id;
}
/**
* @return Email
*/
public function getEmail()
{
return $this->email;
}
/**
* @return GrapesJsBuilder
*/
public function setEmail(Email $email)
{
$this->email = $email;
return $this;
}
/**
* @return string|null
*/
public function getCustomMjml()
{
return $this->customMjml;
}
/**
* @param string $customMjml
*
* @return GrapesJsBuilder
*/
public function setCustomMjml($customMjml)
{
$this->customMjml = $customMjml;
return $this;
}
public function getDraftCustomMjml(): ?string
{
return $this->draftCustomMjml;
}
public function setDraftCustomMjml(?string $draftCustomMjml): void
{
$this->draftCustomMjml = $draftCustomMjml;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Entity;
use Mautic\CoreBundle\Entity\CommonRepository;
/**
* @extends CommonRepository<GrapesJsBuilder>
*/
class GrapesJsBuilderRepository extends CommonRepository
{
public function getTableAlias(): string
{
return 'gjb';
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\EventSubscriber;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\CustomAssetsEvent;
use Mautic\InstallBundle\Install\InstallService;
use MauticPlugin\GrapesJsBuilderBundle\Integration\Config;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class AssetsSubscriber implements EventSubscriberInterface
{
public function __construct(
private Config $config,
private InstallService $installer,
private RequestStack $requestStack,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::VIEW_INJECT_CUSTOM_ASSETS => ['injectAssets', 0],
];
}
public function injectAssets(CustomAssetsEvent $assetsEvent): void
{
if (!$this->installer->checkIfInstalled() || !$this->isMauticAdministrationPage()) {
return;
}
if ($this->config->isPublished()) {
$assetsEvent->addScript('plugins/GrapesJsBuilderBundle/Assets/library/js/dist/builder.js');
$assetsEvent->addStylesheet('plugins/GrapesJsBuilderBundle/Assets/library/js/dist/builder.css');
}
}
/**
* Returns true for routes that starts with /s/.
*/
private function isMauticAdministrationPage(): bool
{
return preg_match('/^\/s\//', $this->requestStack->getCurrentRequest()->getPathInfo()) >= 1;
}
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\EventSubscriber;
use Mautic\EmailBundle\EmailEvents;
use Mautic\EmailBundle\Event as Events;
use Mautic\EmailBundle\Helper\EmailConfigInterface;
use Mautic\EmailBundle\Model\EmailModel;
use MauticPlugin\GrapesJsBuilderBundle\Integration\Config;
use MauticPlugin\GrapesJsBuilderBundle\Model\GrapesJsBuilderModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class EmailSubscriber implements EventSubscriberInterface
{
private string $existingMjml = '';
private string $existingHtml = '';
public function __construct(
private Config $config,
private GrapesJsBuilderModel $grapesJsBuilderModel,
private EmailModel $emailModel,
private EmailConfigInterface $emailConfig,
) {
}
public static function getSubscribedEvents(): array
{
return [
EmailEvents::EMAIL_PRE_SAVE => ['onEmailPreSave', 0],
EmailEvents::EMAIL_POST_SAVE => ['onEmailPostSave', 0],
EmailEvents::EMAIL_POST_DELETE => ['onEmailDelete', 0],
EmailEvents::ON_EMAIL_EDIT_SUBMIT => ['manageEmailDraft'],
];
}
/**
* Stores the current MJML for use when managing drafts.
*/
public function onEmailPreSave(Events\EmailEvent $event): void
{
if (!$this->config->isPublished() || !$this->emailConfig->isDraftEnabled()) {
return;
}
$email = $event->getEmail();
$this->existingHtml = $email->getCustomHtml() ?? '';
if ($grapesJsBuilder = $this->grapesJsBuilderModel->getRepository()->findOneBy(['email' => $email])) {
$this->existingMjml = $grapesJsBuilder->getCustomMjml();
}
}
/**
* Add an entry.
*/
public function onEmailPostSave(Events\EmailEvent $event): void
{
if (!$this->config->isPublished()) {
return;
}
$this->grapesJsBuilderModel->addOrEditEntity($event->getEmail());
}
/**
* Delete an entry.
*/
public function onEmailDelete(Events\EmailEvent $event): void
{
if (!$this->config->isPublished()) {
return;
}
$email = $event->getEmail();
$grapesJsBuilder = $this->grapesJsBuilderModel->getRepository()->findOneBy(['email' => $email]);
if ($grapesJsBuilder) {
$this->grapesJsBuilderModel->getRepository()->deleteEntity($grapesJsBuilder);
}
}
public function manageEmailDraft(Events\EmailEditSubmitEvent $event): void
{
if (!$this->config->isPublished()) {
return;
}
$email = $event->getCurrentEmail();
$grapesJsBuilder = $this->grapesJsBuilderModel->getRepository()->findOneBy(['email' => $email]);
if ($event->isSaveAsDraft()) {
// Set draft MJML and restore previous version when saving a draft
$grapesJsBuilder->setDraftCustomMjml($grapesJsBuilder->getCustomMjml());
$grapesJsBuilder->setCustomMjml($this->existingMjml);
// reset the html of the parent email as well
$email->setCustomHtml($this->existingHtml);
}
if ($event->isApplyDraft()) {
// Remove the draft version when applying - the customMjml is already up to date
$grapesJsBuilder->setDraftCustomMjml(null);
}
if ($event->isDiscardDraft() && $email->hasDraft()) {
$grapesJsBuilder->setDraftCustomMjml(null);
}
$this->grapesJsBuilderModel->getRepository()->saveEntity($grapesJsBuilder);
$this->emailModel->getRepository()->saveEntity($email);
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\EventSubscriber;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\CustomContentEvent;
use Mautic\EmailBundle\Entity\Email;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilder;
use MauticPlugin\GrapesJsBuilderBundle\Integration\Config;
use MauticPlugin\GrapesJsBuilderBundle\Model\GrapesJsBuilderModel;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\RouterInterface;
use Twig\Environment;
class InjectCustomContentSubscriber implements EventSubscriberInterface
{
public function __construct(
private Config $config,
private GrapesJsBuilderModel $grapesJsBuilderModel,
private Environment $twig,
private RequestStack $requestStack,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::VIEW_INJECT_CUSTOM_CONTENT => ['injectViewCustomContent', 0],
];
}
public function injectViewCustomContent(CustomContentEvent $customContentEvent): void
{
if (!$this->config->isPublished()) {
return;
}
$passParams = [];
$parameters = $customContentEvent->getVars();
if ('email.settings.advanced' === $customContentEvent->getContext()) {
// Inject MJML form within mail page
if (empty($parameters['email']) || !$parameters['email'] instanceof Email) {
return;
}
$passParams = ['customMjml' => ''];
if ($this->requestStack->getCurrentRequest()->request->has('grapesjsbuilder')) {
$data = $this->requestStack->getCurrentRequest()->get('grapesjsbuilder', '');
if (isset($data['customMjml'])) {
$passParams['customMjml'] = $data['customMjml'];
}
}
$grapesJsBuilder = $this->grapesJsBuilderModel->getRepository()->findOneBy(['email' => $parameters['email']]);
if ('POST' !== $this->requestStack->getCurrentRequest()->getMethod()) {
if (!$grapesJsBuilder instanceof GrapesJsBuilder && $parameters['email']->getIsClone()) {
$grapesJsBuilder = $this->grapesJsBuilderModel->getGrapesJsFromEmailId(
$parameters['email']->getClonedId()
);
}
if ($grapesJsBuilder instanceof GrapesJsBuilder) {
$passParams['customMjml'] = $grapesJsBuilder->getCustomMjml();
}
}
$content = $this->twig->render(
'@GrapesJsBuilder/Setting/fields.html.twig',
$passParams
);
$customContentEvent->addContent($content);
} elseif ('page.header.left' === $customContentEvent->getContext()) {
// Inject fileManager URL
$passParams['dataAssets'] = $this->router->generate('grapesjsbuilder_assets', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
$passParams['dataUpload'] = $this->router->generate('grapesjsbuilder_upload', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
$passParams['dataDelete'] = $this->router->generate('grapesjsbuilder_delete', [], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
$content = $this->twig->render(
'@GrapesJsBuilder/Setting/vars.html.twig',
$passParams
);
$customContentEvent->addContent($content);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\EventSubscriber;
use JMS\Serializer\EventDispatcher\Events;
use JMS\Serializer\EventDispatcher\EventSubscriberInterface;
use JMS\Serializer\EventDispatcher\ObjectEvent;
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\Metadata\StaticPropertyMetadata;
use Mautic\EmailBundle\Entity\Email;
use MauticPlugin\GrapesJsBuilderBundle\Integration\Config;
use MauticPlugin\GrapesJsBuilderBundle\Model\GrapesJsBuilderModel;
class SerializerSubscriber implements EventSubscriberInterface
{
public function __construct(
private GrapesJsBuilderModel $grapesJsBuilderModel,
private Config $config,
) {
}
/**
* @return array<int, array<string, string>>
*/
public static function getSubscribedEvents(): array
{
return [
[
'event' => Events::POST_SERIALIZE,
'method' => 'addCustomMJML',
],
];
}
public function addCustomMJML(ObjectEvent $event): void
{
if (!$this->config->isPublished()) {
return;
}
$object = $event->getObject();
if (!$object instanceof Email) {
return;
}
$grapesJsBuilder = $this->grapesJsBuilderModel->getRepository()->findOneBy(['email' => $object]);
if (is_null($grapesJsBuilder)) {
return;
}
// Add it to the serialized data.
$visitor = $event->getContext()->getVisitor();
if ($visitor instanceof JsonSerializationVisitor && !empty($grapesJsBuilder->getCustomMjml())) {
$visitor->visitProperty(
new StaticPropertyMetadata(
'', 'grapesjsbuilder', ['customMjml' => $grapesJsBuilder->getCustomMjml()]
), ['customMjml' => $grapesJsBuilder->getCustomMjml()]
);
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle;
use Mautic\IntegrationsBundle\Bundle\AbstractPluginBundle;
class GrapesJsBuilderBundle extends AbstractPluginBundle
{
}

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Helper;
use Mautic\CoreBundle\Exception\FileUploadException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\FileUploader;
use Mautic\CoreBundle\Helper\PathsHelper;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class FileManager
{
public const GRAPESJS_IMAGES_DIRECTORY = '';
public function __construct(
private FileUploader $fileUploader,
private CoreParametersHelper $coreParametersHelper,
private PathsHelper $pathsHelper,
) {
}
/**
* @return array
*
* @throws FileUploadException
*/
public function uploadFiles($request)
{
if (isset($request->files->all()['files'])) {
$files = $request->files->all()['files'];
$uploadDir = $this->getUploadDir();
$uploadedFiles = [];
foreach ($files as $file) {
$this->fileUploader->validateImage($file);
}
foreach ($files as $file) {
$uploadedFiles[] = $this->getFullUrl($this->fileUploader->upload($uploadDir, $file));
}
}
return $uploadedFiles;
}
/**
* @param string $fileName
*/
public function deleteFile($fileName): void
{
$this->fileUploader->delete($this->getCompleteFilePath($fileName));
}
/**
* @param string $fileName
*/
public function getCompleteFilePath($fileName): string
{
$uploadDir = $this->getUploadDir();
return $uploadDir.$fileName;
}
private function getUploadDir(): string
{
return $this->getGrapesJsImagesPath(true);
}
public function getFullUrl($fileName, $separator = '/'): string
{
// if a static_url (CDN) is configured use that, otherwise use the site url
$url = $this->coreParametersHelper->get('static_url') ?? $this->coreParametersHelper->get('site_url');
return $url
.$separator
.$this->getGrapesJsImagesPath(false, $separator)
.$fileName;
}
/**
* @param bool $fullPath
* @param string $separator
*/
private function getGrapesJsImagesPath($fullPath = false, $separator = '/'): string
{
return $this->pathsHelper->getSystemPath('images', $fullPath)
.$separator
.self::GRAPESJS_IMAGES_DIRECTORY;
}
/**
* @deprecated since Mautic 5.2, to be removed in 6.0. Use FileManager::getMediaFiles instead
*/
public function getImages(): array
{
$files = [];
$uploadDir = $this->getUploadDir();
$fileSystem = new Filesystem();
if (!$fileSystem->exists($uploadDir)) {
try {
$fileSystem->mkdir($uploadDir);
} catch (IOException) {
return $files;
}
}
$finder = new Finder();
$finder->files()->in($uploadDir);
foreach ($finder as $file) {
// exclude certain folders from grapesjs file manager
if (in_array($file->getRelativePath(), $this->coreParametersHelper->get('image_path_exclude'))) {
continue;
}
if ($size = @getimagesize($this->getCompleteFilePath($file->getRelativePathname()))) {
$files[] = [
'src' => $this->getFullUrl($file->getRelativePathname()),
'width' => $size[0],
'type' => 'image',
'height' => $size[1],
];
} else {
$files[] = $this->getFullUrl($file->getRelativePathname());
}
}
return $files;
}
/**
* @return array<string, mixed>
*/
public function getMediaFiles(int $page, int $limit): array
{
$files = [];
$uploadDir = $this->getUploadDir();
$fileSystem = new Filesystem();
if (!$fileSystem->exists($uploadDir)) {
try {
$fileSystem->mkdir($uploadDir);
} catch (IOException) {
return [
'data' => [],
'page' => $page,
'limit' => $limit,
'totalItems' => 0,
'totalPages' => 0,
'hasNextPage' => false,
'hasPreviousPage' => false,
];
}
}
$finder = new Finder();
$finder->files()->in($uploadDir)->sortByModifiedTime()->reverseSorting();
$totalFiles = iterator_count($finder);
$totalPages = (int) ceil($totalFiles / $limit);
// Check if the requested page is out of range
if ($page < 1 || $page > $totalPages) {
return [
'data' => [],
'page' => $page,
'limit' => $limit,
'totalItems' => $totalFiles,
'totalPages' => $totalPages,
'hasNextPage' => $page < $totalPages,
'hasPreviousPage' => $page > 1,
];
}
$offset = ($page - 1) * $limit;
$filesIterator = new \LimitIterator($finder->getIterator(), $offset, $limit);
foreach ($filesIterator as $file) {
if (in_array($file->getRelativePath(), $this->coreParametersHelper->get('image_path_exclude'))) {
continue;
}
$fileInfo = $this->getFileInfo($file);
if ($fileInfo) {
$files[] = $fileInfo;
}
}
return [
'data' => $files,
'page' => $page,
'limit' => $limit,
'totalItems' => $totalFiles,
'totalPages' => $totalPages,
'hasNextPage' => $page < $totalPages,
'hasPreviousPage' => $page > 1,
];
}
/**
* @return array<string, mixed>|null
*/
private function getFileInfo(SplFileInfo $file): ?array
{
$filePath = $this->getCompleteFilePath($file->getRelativePathname());
$size = @getimagesize($filePath);
if ($size) {
return [
'src' => $this->getFullUrl($file->getRelativePathname()),
'width' => $size[0],
'height' => $size[1],
'type' => 'image',
];
} elseif (in_array($file->getExtension(), ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])) {
return [
'src' => $this->getFullUrl($file->getRelativePathname()),
'type' => 'document',
];
}
return null;
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\InstallFixtures\ORM;
use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface;
use Doctrine\Common\DataFixtures\AbstractFixture;
use Doctrine\Common\DataFixtures\OrderedFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\PluginBundle\Entity\Integration;
use Mautic\PluginBundle\Entity\Plugin;
class GrapesJsData extends AbstractFixture implements OrderedFixtureInterface, FixtureGroupInterface
{
public function __construct(private CoreParametersHelper $coreParametersHelper)
{
}
public static function getGroups(): array
{
return ['group_install', 'group_mautic_install_data'];
}
public function load(ObjectManager $manager): void
{
$applicationDir = $this->coreParametersHelper->get('mautic.application_dir');
$grapeJsBuilderConfigPath = $applicationDir.'/plugins/GrapesJsBuilderBundle/Config/config.php';
if (!file_exists($grapeJsBuilderConfigPath)) {
return;
}
$parameters = include $grapeJsBuilderConfigPath;
if (!is_array($parameters)) {
return;
}
$plugin = new Plugin();
$plugin->setName($parameters['name']);
$plugin->setDescription($parameters['description']);
$plugin->setVersion($parameters['version']);
$plugin->setAuthor($parameters['author']);
$plugin->setBundle('GrapesJsBuilderBundle');
$manager->persist($plugin);
$integration = new Integration();
$integration->setIsPublished(true);
$integration->setName('GrapesJsBuilder');
$integration->setPlugin($plugin);
$manager->persist($integration);
$manager->flush();
}
public function getOrder(): int
{
return 1;
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Integration;
use Mautic\IntegrationsBundle\Exception\IntegrationNotFoundException;
use Mautic\IntegrationsBundle\Helper\IntegrationsHelper;
use Mautic\PluginBundle\Entity\Integration;
class Config
{
public function __construct(
private IntegrationsHelper $integrationsHelper,
) {
}
public function isPublished(): bool
{
try {
$integration = $this->getIntegrationEntity();
return (bool) $integration->getIsPublished() ?: false;
} catch (IntegrationNotFoundException) {
return false;
}
}
/**
* @return mixed[]
*/
public function getFeatureSettings(): array
{
try {
$integration = $this->getIntegrationEntity();
return $integration->getFeatureSettings() ?: [];
} catch (IntegrationNotFoundException) {
return [];
}
}
/**
* @throws IntegrationNotFoundException
*/
public function getIntegrationEntity(): Integration
{
$integrationObject = $this->integrationsHelper->getIntegration(GrapesJsBuilderIntegration::NAME);
return $integrationObject->getIntegrationConfiguration();
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Integration;
use Mautic\IntegrationsBundle\Integration\BasicIntegration;
use Mautic\IntegrationsBundle\Integration\ConfigurationTrait;
use Mautic\IntegrationsBundle\Integration\Interfaces\BasicInterface;
class GrapesJsBuilderIntegration extends BasicIntegration implements BasicInterface
{
use ConfigurationTrait;
public const NAME = 'grapesjsbuilder';
public const DISPLAY_NAME = 'GrapesJS';
public function getName(): string
{
return self::NAME;
}
public function getDisplayName(): string
{
return self::DISPLAY_NAME;
}
public function getIcon(): string
{
return 'plugins/GrapesJsBuilderBundle/Assets/img/grapesjsbuilder.png';
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Integration\Support;
use Mautic\IntegrationsBundle\Integration\Interfaces\BuilderInterface;
use MauticPlugin\GrapesJsBuilderBundle\Integration\GrapesJsBuilderIntegration;
class BuilderSupport extends GrapesJsBuilderIntegration implements BuilderInterface
{
/**
* @var string[]
*/
private array $featuresSupported = ['email', 'page'];
public function isSupported(string $featureName): bool
{
return in_array($featureName, $this->featuresSupported);
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Integration\Support;
use Mautic\IntegrationsBundle\Integration\DefaultConfigFormTrait;
use Mautic\IntegrationsBundle\Integration\Interfaces\ConfigFormInterface;
use MauticPlugin\GrapesJsBuilderBundle\Integration\GrapesJsBuilderIntegration;
class ConfigSupport extends GrapesJsBuilderIntegration implements ConfigFormInterface
{
use DefaultConfigFormTrait;
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Model\AbstractCommonModel;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Model\EmailModel;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilder;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilderRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* @extends AbstractCommonModel<GrapesJsBuilder>
*/
class GrapesJsBuilderModel extends AbstractCommonModel
{
public function __construct(
private RequestStack $requestStack,
private EmailModel $emailModel,
EntityManager $em,
CorePermissions $security,
EventDispatcherInterface $dispatcher,
UrlGeneratorInterface $router,
Translator $translator,
UserHelper $userHelper,
LoggerInterface $mauticLogger,
CoreParametersHelper $coreParametersHelper,
) {
parent::__construct($em, $security, $dispatcher, $router, $translator, $userHelper, $mauticLogger, $coreParametersHelper);
}
/**
* @return GrapesJsBuilderRepository
*/
public function getRepository()
{
/** @var GrapesJsBuilderRepository $repository */
$repository = $this->em->getRepository(GrapesJsBuilder::class);
$repository->setTranslator($this->translator);
return $repository;
}
/**
* Add or edit email settings entity based on request.
*/
public function addOrEditEntity(Email $email): void
{
if ($this->emailModel->isUpdatingTranslationChildren()) {
return;
}
$grapesJsBuilder = $this->getRepository()->findOneBy(['email' => $email]);
if (!$grapesJsBuilder) {
$grapesJsBuilder = new GrapesJsBuilder();
$grapesJsBuilder->setEmail($email);
}
$currentRequest = $this->requestStack->getCurrentRequest();
if ($currentRequest && $currentRequest->request->has('grapesjsbuilder')) {
$data = $this->requestStack->getCurrentRequest()->get('grapesjsbuilder', '');
if (isset($data['customMjml'])) {
$grapesJsBuilder->setCustomMjml($data['customMjml']);
}
$this->getRepository()->saveEntity($grapesJsBuilder);
$customHtml = $this->requestStack->getCurrentRequest()->get('emailform')['customHtml'] ?? null;
if (is_null($customHtml)) {
$customHtml = $this->requestStack->getCurrentRequest()->get('customHtml') ?? null;
}
$email->setCustomHtml($customHtml);
$this->emailModel->getRepository()->saveEntity($email);
}
}
public function getGrapesJsFromEmailId(?int $emailId)
{
if ($email = $this->emailModel->getEntity($emailId)) {
return $this->getRepository()->findOneBy(['email' => $email]);
}
}
}

View File

@@ -0,0 +1,5 @@
# GrapesJS Builder with MJML support for Mautic
## This plugin is managed centrally in https://github.com/mautic/mautic/blob/head/plugins/GrapesJsBuilderBundle and this is a read-only mirror repository.
**📣 Please make PRs and issues against Mautic Core, not here!**

View File

@@ -0,0 +1,5 @@
{% set templateObject = {
'templateHtml': templateHtml,
'templateMjml': templateMjml,
} %}
{{ templateObject | json_encode() | raw }}

View File

@@ -0,0 +1,7 @@
<div class="row hide">
<div class="form-group col-xs-12 ">
<div class="input-group">
<textarea id="grapesjsbuilder_customMjml" class="form-control builder-mjml" name="grapesjsbuilder[customMjml]">{{ customMjml }}</textarea>
</div>
</div>
</div>

View File

@@ -0,0 +1 @@
<div id="grapesjsbuilder_assets" class="hide" data-assets="{{ dataAssets }}" data-upload="{{ dataUpload }}" data-delete="{{ dataDelete }}"></div>

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Email;
use Mautic\PluginBundle\Entity\Integration;
use Mautic\PluginBundle\Entity\Plugin;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilder;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilderRepository;
class AssertCustomMjmlTest extends MauticMysqlTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->createIntegration();
}
/**
* @throws \Doctrine\ORM\OptimisticLockException
* @throws \Doctrine\ORM\Exception\ORMException
*/
public function testAssertCustomMjml(): void
{
// Create email & add GrapesJs to it.
$email = $this->createEmail();
$this->addToGrapesJsBuilder($email);
$emailId = $email->getId();
// Get the Email via API and assert customMjml.
$this->client->request('GET', '/api/emails/'.$emailId);
$this->assertResponseStatusCodeSame(200);
$content = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($content['email']['grapesjsbuilder']['customMjml']);
}
/**
* @throws \Doctrine\ORM\Exception\NotSupported
*/
public function testAssertCustomHtmlAndCustomMjml(): void
{
// Create email using an API call and add GrapesJS into it.
$responseData = $this->createEmailViaApi();
$emailId = $responseData['email']['id'];
$email = $this->em->getRepository(Email::class)->find($emailId);
$this->addToGrapesJsBuilder($email);
// Get email & check for both customHtml & customMjml in the response.
$this->client->request('GET', '/api/emails/'.$emailId);
$this->assertResponseStatusCodeSame(200);
$content = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($content['email']['customHtml']);
$this->assertNotEmpty($content['email']['grapesjsbuilder']['customMjml']);
}
/**
* @throws \Doctrine\ORM\Exception\NotSupported
*/
private function getRepository(): GrapesJsBuilderRepository
{
/** @var GrapesJsBuilderRepository $repository */
$repository = $this->em->getRepository(GrapesJsBuilder::class);
$repository->setTranslator($this->getTranslatorMock());
return $repository;
}
/**
* @throws \Doctrine\ORM\Exception\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
private function createEmail(): Email
{
$email = new Email();
$email->setName('Test email');
$email->setSubject('Test email subject');
$email->setEmailType('template');
$email->setCustomHtml('<html></html>');
$email->setIsPublished(true);
$this->em->persist($email);
$this->em->flush();
return $email;
}
private function getTranslatorMock(): Translator
{
$translator = $this->createMock(Translator::class);
$translator->method('hasId')
->willReturn(false);
return $translator;
}
private function addToGrapesJsBuilder(Email $email): void
{
$grapesJsBuilder = new GrapesJsBuilder();
$grapesJsBuilder->setEmail($email);
$grapesJsBuilder->setCustomMjml('<mjml>></mjml>');
$this->getRepository()->saveEntity($grapesJsBuilder);
}
private function createEmailViaApi(): mixed
{
$emailData = [
'name' => 'Test email',
'subject' => 'Test email subject',
'emailType' => 'template',
'customHtml' => '<html></html>',
'isPublished' => true,
];
$this->client->request('POST', '/api/emails/new', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode($emailData));
$this->assertResponseStatusCodeSame(201);
return json_decode($this->client->getResponse()->getContent(), true);
}
/**
* @throws \Doctrine\ORM\Exception\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
private function createIntegration(): void
{
$plugin = new Plugin();
$plugin->setName('GrapesJS Builder');
$plugin->setBundle('GrapesJsBuilderBundle');
$this->em->persist($plugin);
$integration = new Integration();
$integration->setPlugin($plugin);
$integration->setIsPublished(true);
$integration->setName('grapesjsbuilder');
$this->em->persist($integration);
$this->em->flush();
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Tests\Functional\Controller;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Response;
final class FileManagerControllerFunctionalTest extends MauticMysqlTestCase
{
private const ASSETS_ENDPOINT = '/s/grapesjsbuilder/media';
private const UPLOAD_ENDPOINT = '/s/grapesjsbuilder/upload';
private const DELETE_ENDPOINT = '/s/grapesjsbuilder/delete';
private const IMAGE_COUNT = 3;
/** @var array<string> */
private array $tempFilePaths = [];
protected function beforeTearDown(): void
{
$this->cleanupTempFiles();
}
public function testAssetsManagerWorkflow(): void
{
$initialAssetCount = $this->getAssetCount();
$uploadedFiles = $this->uploadImages();
$this->assertUploadSuccessful($uploadedFiles);
$newAssetCount = $this->getAssetCount();
$this->assertEquals($initialAssetCount + self::IMAGE_COUNT, $newAssetCount);
$this->testPagination($newAssetCount);
$this->testRecentlyAddedFilesAppearFirst($uploadedFiles);
$this->deleteUploadedFiles($uploadedFiles);
$finalAssetCount = $this->getAssetCount();
$this->assertEquals($initialAssetCount, $finalAssetCount);
}
private function getAssetCount(): int
{
$response = $this->makeRequest('GET', self::ASSETS_ENDPOINT);
$content = $this->getJsonResponse($response);
$this->assertArrayHasKey('data', $content);
$this->assertArrayHasKey('page', $content);
$this->assertArrayHasKey('limit', $content);
$this->assertArrayHasKey('totalItems', $content);
$this->assertArrayHasKey('totalPages', $content);
$this->assertArrayHasKey('hasNextPage', $content);
$this->assertArrayHasKey('hasPreviousPage', $content);
return $content['totalItems'];
}
private function testPagination(int $totalAssets): void
{
$limit = 2;
$totalPages = ceil($totalAssets / $limit);
for ($page = 1; $page <= $totalPages; ++$page) {
$response = $this->makeRequest('GET', self::ASSETS_ENDPOINT."?limit={$limit}&page={$page}");
$content = $this->getJsonResponse($response);
$this->assertArrayHasKey('data', $content);
$this->assertArrayHasKey('page', $content);
$this->assertArrayHasKey('limit', $content);
$this->assertArrayHasKey('totalItems', $content);
$this->assertArrayHasKey('totalPages', $content);
$this->assertArrayHasKey('hasNextPage', $content);
$this->assertArrayHasKey('hasPreviousPage', $content);
$this->assertEquals($page, $content['page']);
$this->assertEquals($limit, $content['limit']);
$this->assertEquals($totalAssets, $content['totalItems']);
$this->assertEquals($totalPages, $content['totalPages']);
$this->assertEquals($page < $totalPages, $content['hasNextPage']);
$this->assertEquals($page > 1, $content['hasPreviousPage']);
$expectedItemCount = ($page < $totalPages) ? $limit : (($totalAssets % $limit) ?: $limit);
$this->assertCount($expectedItemCount, $content['data']);
foreach ($content['data'] as $item) {
$this->assertArrayHasKey('src', $item);
$this->assertArrayHasKey('width', $item);
$this->assertArrayHasKey('height', $item);
$this->assertArrayHasKey('type', $item);
}
}
// Test invalid page
$response = $this->makeRequest('GET', self::ASSETS_ENDPOINT."?limit={$limit}&page=".($totalPages + 1));
$content = $this->getJsonResponse($response);
$this->assertEmpty($content['data']);
}
/**
* @param array<string> $uploadedFiles
*/
private function testRecentlyAddedFilesAppearFirst(array $uploadedFiles): void
{
$response = $this->makeRequest('GET', self::ASSETS_ENDPOINT);
$content = $this->getJsonResponse($response);
$this->assertArrayHasKey('data', $content);
$this->assertNotEmpty($content['data']);
$assetList = $content['data'];
$uploadedFileNames = array_map([$this, 'getFileNameFromUrl'], $uploadedFiles);
// Check if the first 'IMAGE_COUNT' assets in the list are the recently uploaded files
for ($i = 0; $i < self::IMAGE_COUNT; ++$i) {
$this->assertArrayHasKey($i, $assetList);
$this->assertArrayHasKey('src', $assetList[$i]);
$assetFileName = $this->getFileNameFromUrl($assetList[$i]['src']);
$this->assertContains($assetFileName, $uploadedFileNames, 'Recently uploaded file not found in the first {self::IMAGE_COUNT} assets');
}
}
/**
* @return array<string>
*/
private function uploadImages(): array
{
$imageFiles = $this->createTempImageFiles();
$response = $this->makeRequest('POST', self::UPLOAD_ENDPOINT, [], ['files' => $imageFiles]);
return $this->getJsonResponse($response)['data'];
}
/**
* @return array<UploadedFile>
*/
private function createTempImageFiles(): array
{
$imageFiles = [];
for ($i = 1; $i <= self::IMAGE_COUNT; ++$i) {
$imagePath = sys_get_temp_dir()."/test-image-{$i}.png";
$this->createImage($imagePath);
$this->tempFilePaths[] = $imagePath;
$imageFiles[] = new UploadedFile($imagePath, "test-image-{$i}.png", 'image/png', null, true);
}
return $imageFiles;
}
private function createImage(string $path): void
{
$image = imagecreatetruecolor(100, 100);
imagepng($image, $path);
imagedestroy($image);
}
/**
* @param array<string> $uploadedFiles
*/
private function assertUploadSuccessful(array $uploadedFiles): void
{
$this->assertCount(self::IMAGE_COUNT, $uploadedFiles);
}
/**
* @param array<string> $uploadedFiles
*/
private function deleteUploadedFiles(array $uploadedFiles): void
{
foreach ($uploadedFiles as $uploadedFile) {
$fileName = $this->getFileNameFromUrl($uploadedFile);
$this->makeRequest('GET', self::DELETE_ENDPOINT."?filename={$fileName}");
}
}
private function getFileNameFromUrl(string $url): string
{
$fileUrlParts = explode('/', $url);
return end($fileUrlParts);
}
/**
* @param array<string, mixed> $parameters
* @param array<string, mixed> $files
*/
private function makeRequest(string $method, string $endpoint, array $parameters = [], array $files = []): Response
{
$this->client->request($method, $endpoint, $parameters, $files);
$response = $this->client->getResponse();
$this->assertSame(Response::HTTP_OK, $response->getStatusCode());
return $response;
}
/**
* @return array<string, mixed>
*/
private function getJsonResponse(Response $response): array
{
return json_decode($response->getContent(), true);
}
private function cleanupTempFiles(): void
{
foreach ($this->tempFilePaths as $path) {
if (file_exists($path)) {
unlink($path);
}
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Tests\InstallFixtures\ORM;
use Mautic\CoreBundle\Test\MauticMysqlTestCase;
use Mautic\PluginBundle\Entity\Integration;
use Mautic\PluginBundle\Entity\Plugin;
use MauticPlugin\GrapesJsBuilderBundle\InstallFixtures\ORM\GrapesJsData;
use PHPUnit\Framework\Assert;
class GrapeJsDataTest extends MauticMysqlTestCase
{
protected $useCleanupRollback = false;
public function testGetGroups(): void
{
Assert::assertSame(['group_install', 'group_mautic_install_data'], GrapesJsData::getGroups());
}
public function testLoad(): void
{
$findOneByCriteria = [
'name' => 'GrapesJS Builder',
'description' => 'GrapesJS Builder with MJML support for Mautic',
'version' => '1.0.0',
'author' => 'Mautic Community',
'bundle' => 'GrapesJsBuilderBundle',
];
$plugin = $this->em->getRepository(Plugin::class)->findOneBy($findOneByCriteria);
self::assertNull($plugin);
$this->loadFixtures([GrapesJsData::class]);
$plugin = $this->em->getRepository(Plugin::class)->findOneBy($findOneByCriteria);
self::assertInstanceOf(Plugin::class, $plugin);
$integration = $this->em->getRepository(Integration::class)->findOneBy(
[
'isPublished' => true,
'name' => 'GrapesJsBuilder',
'plugin' => $plugin,
]
);
self::assertInstanceOf(Integration::class, $integration);
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Tests\Unit\EventSubscriber;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
use Mautic\EmailBundle\Event\EmailEditSubmitEvent;
use Mautic\EmailBundle\Helper\EmailConfigInterface;
use Mautic\EmailBundle\Model\EmailModel;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilder;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilderRepository;
use MauticPlugin\GrapesJsBuilderBundle\EventSubscriber\EmailSubscriber;
use MauticPlugin\GrapesJsBuilderBundle\Integration\Config;
use MauticPlugin\GrapesJsBuilderBundle\Model\GrapesJsBuilderModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class EmailSubscriberTest extends TestCase
{
/** @var MockObject&Config */
private MockObject $config;
/** @var MockObject&GrapesJsBuilderModel */
private MockObject $grapesJsBuilderModel;
/** @var MockObject&GrapesJsBuilderRepository */
private MockObject $grapesJsBuilderRepo;
private EmailModel|MockObject $emailModel;
private EmailConfigInterface|MockObject $emailConfig;
private EmailSubscriber $subscriber;
public function setUp(): void
{
$this->config = $this->createMock(Config::class);
$this->grapesJsBuilderModel = $this->createMock(GrapesJsBuilderModel::class);
$this->emailModel = $this->createMock(EmailModel::class);
$this->emailConfig = $this->createMock(EmailConfigInterface::class);
$this->grapesJsBuilderRepo = $this->createMock(GrapesJsBuilderRepository::class);
$this->subscriber = new EmailSubscriber($this->config, $this->grapesJsBuilderModel, $this->emailModel, $this->emailConfig);
$this->emailModel->method('getRepository')
->willReturn($this->createMock(EmailRepository::class));
$this->grapesJsBuilderModel->method('getRepository')
->willReturn($this->grapesJsBuilderRepo);
}
public function testManageEmailDraftExitsWhenPluginNotPublished(): void
{
$event = $this->createMock(EmailEditSubmitEvent::class);
$event->expects($this->never())
->method('getCurrentEmail');
$this->config->expects($this->once())
->method('isPublished')
->willReturn(false);
$this->subscriber->manageEmailDraft($event);
}
public function testManageEmailDraftHandlesSaveAsDraft(): void
{
$event = $this->createMock(EmailEditSubmitEvent::class);
$event->expects($this->once())
->method('getCurrentEmail')
->willReturn($this->createMock(Email::class));
$event->expects($this->once())
->method('isSaveAsDraft')
->willReturn(true);
$this->grapesJsBuilderRepo->method('findOneBy')
->willReturn($grapesJsBuilder = $this->createMock(GrapesJsBuilder::class));
$this->config->expects($this->once())
->method('isPublished')
->willReturn(true);
$grapesJsBuilder->expects($this->once())->method('setDraftCustomMjml');
$grapesJsBuilder->expects($this->once())->method('setCustomMjml');
$this->subscriber->manageEmailDraft($event);
}
public function testManageEmailDraftHandlesApply(): void
{
$event = $this->createMock(EmailEditSubmitEvent::class);
$event->expects($this->once())
->method('getCurrentEmail')
->willReturn($this->createMock(Email::class));
$event->expects($this->once())
->method('isApplyDraft')
->willReturn(true);
$this->grapesJsBuilderRepo->method('findOneBy')
->willReturn($grapesJsBuilder = $this->createMock(GrapesJsBuilder::class));
$this->config->expects($this->once())
->method('isPublished')
->willReturn(true);
$grapesJsBuilder->expects($this->once())->method('setDraftCustomMjml');
$grapesJsBuilder->expects($this->never())->method('setCustomMjml');
$this->subscriber->manageEmailDraft($event);
}
public function testManageEmailDraftHandlesDiscardDraft(): void
{
$event = $this->createMock(EmailEditSubmitEvent::class);
$event->expects($this->once())
->method('getCurrentEmail')
->willReturn($mockEmail = $this->createMock(Email::class));
$event->expects($this->once())
->method('isDiscardDraft')
->willReturn(true);
$mockEmail->expects($this->once())
->method('hasDraft')
->willReturn(true);
$this->grapesJsBuilderRepo->method('findOneBy')
->willReturn($grapesJsBuilder = $this->createMock(GrapesJsBuilder::class));
$this->config->expects($this->once())
->method('isPublished')
->willReturn(true);
$grapesJsBuilder->expects($this->once())
->method('setDraftCustomMjml')
->with(null);
$this->subscriber->manageEmailDraft($event);
}
}

View File

@@ -0,0 +1,234 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\GrapesJsBuilderBundle\Tests\Unit\Model;
use Doctrine\ORM\EntityManager;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\CoreBundle\Security\Permissions\CorePermissions;
use Mautic\CoreBundle\Translation\Translator;
use Mautic\EmailBundle\Entity\Email;
use Mautic\EmailBundle\Entity\EmailRepository;
use Mautic\EmailBundle\Model\EmailModel;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilder;
use MauticPlugin\GrapesJsBuilderBundle\Entity\GrapesJsBuilderRepository;
use MauticPlugin\GrapesJsBuilderBundle\Model\GrapesJsBuilderModel;
use PHPUnit\Framework\Assert;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Routing\Router;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
class GrapesJsBuilderModelTest extends \PHPUnit\Framework\TestCase
{
public function testAddOrEditEntityWithoutMatchingEntityAndNoRequestQuery(): void
{
$requestStack = new class extends RequestStack {
public function __construct()
{
}
public function getCurrentRequest(): Request
{
return new Request();
}
};
$emailRepository = new class extends EmailRepository {
public int $saveEntityCallCount = 0;
public function __construct()
{
}
public function saveEntity($entity, $flush = true): void
{
++$this->saveEntityCallCount;
}
};
$emailModel = $this->getEmailModel($emailRepository);
$grapesJsBuilderRepository = new class extends GrapesJsBuilderRepository {
public int $saveEntityCallCount = 0;
public function __construct()
{
}
public function findOneBy(array $criteria, ?array $orderBy = null)
{
return null;
}
public function saveEntity($entity, $flush = true): void
{
++$this->saveEntityCallCount;
}
};
$entityManager = new class($grapesJsBuilderRepository) extends EntityManager {
public function __construct(
private GrapesJsBuilderRepository $grapesJsBuilderRepository,
) {
}
public function getRepository($entityName)
{
Assert::assertSame(GrapesJsBuilder::class, $entityName);
return $this->grapesJsBuilderRepository; // @phpstan-ignore-line
}
};
$email = new Email();
$grapeJsBuilderModel = new GrapesJsBuilderModel(
$requestStack,
$emailModel,
$entityManager,
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Router::class),
$this->getTranslator(),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class)
);
$grapeJsBuilderModel->addOrEditEntity($email);
// Not a GrapeJs email, so we are not saving anything.
Assert::assertSame(0, $grapesJsBuilderRepository->saveEntityCallCount);
Assert::assertSame(0, $emailRepository->saveEntityCallCount);
}
public function testAddOrEditEntityWithoutMatchingEntityAndGrapeRequestQuery(): void
{
$requestStack = new class extends RequestStack {
public function __construct()
{
}
public function getCurrentRequest(): Request
{
return new Request(
[],
[
'grapesjsbuilder' => [
'customMjml' => '</mjml>',
],
'emailform' => [
'customHtml' => '</html>',
],
]
);
}
};
$emailRepository = new class extends EmailRepository {
public int $saveEntityCallCount = 0;
public function __construct()
{
}
/**
* @param Email $entity
*/
public function saveEntity($entity, $flush = true): void
{
++$this->saveEntityCallCount;
Assert::assertSame('</html>', $entity->getCustomHtml());
}
};
$emailModel = $this->getEmailModel($emailRepository);
$grapesJsBuilderRepository = new class extends GrapesJsBuilderRepository {
public int $saveEntityCallCount = 0;
public function __construct()
{
}
public function findOneBy(array $criteria, ?array $orderBy = null)
{
return null;
}
/**
* @param GrapesJsBuilder $entity
*/
public function saveEntity($entity, $flush = true): void
{
++$this->saveEntityCallCount;
Assert::assertSame('</mjml>', $entity->getCustomMjml());
}
};
$entityManager = new class($grapesJsBuilderRepository) extends EntityManager {
public function __construct(
private GrapesJsBuilderRepository $grapesJsBuilderRepository,
) {
}
public function getRepository($entityName)
{
Assert::assertSame(GrapesJsBuilder::class, $entityName);
return $this->grapesJsBuilderRepository; // @phpstan-ignore-line
}
};
$email = new Email();
$grapeJsBuilderModel = new GrapesJsBuilderModel(
$requestStack,
$emailModel,
$entityManager,
$this->createMock(CorePermissions::class),
$this->createMock(EventDispatcherInterface::class),
$this->createMock(Router::class),
$this->getTranslator(),
$this->createMock(UserHelper::class),
$this->createMock(LoggerInterface::class),
$this->createMock(CoreParametersHelper::class)
);
$grapeJsBuilderModel->addOrEditEntity($email);
// Saving the entities now.
Assert::assertSame(1, $grapesJsBuilderRepository->saveEntityCallCount);
Assert::assertSame(1, $emailRepository->saveEntityCallCount);
}
private function getEmailModel(EmailRepository $emailRepository): EmailModel
{
return new class($emailRepository) extends EmailModel {
public function __construct(
private EmailRepository $emailRepository,
) {
}
public function getRepository(): EmailRepository
{
return $this->emailRepository;
}
};
}
private function getTranslator(): Translator
{
return new class extends Translator {
public function __construct()
{
}
};
}
}

View File

@@ -0,0 +1,15 @@
grapesjsbuilder.sourceEditBtnLabel="Upravit"
grapesjsbuilder.sourceCancelBtnLabel="Zrušit"
grapesjsbuilder.sourceEditModalTitle="Upravit kód"
grapesjsbuilder.deleteAssetConfirmText="Opravdu chete smazat tento soubor?"
grapesjsbuilder.categorySectionLabel="Sekce"
grapesjsbuilder.categoryBlockLabel="Bloky"
grapesjsbuilder.dynamicContentBlockLabel="Dynamický obsah"
grapesjsbuilder.dynamicContentBtnLabel="Uložit"
grapesjsbuilder.dynamicContentModalTitle="Upravit dynamický obsah"
grapesjsbuilder.assetManager.noAssets="Nejsou zde žádné <b>assety</b>, přetáhněte sem soubory pro nahrání."
grapesjsbuilder.buttonBlockLabel="Tlačítko"
grapesjsbuilder.components.names.twoColumnThirdSevens="2 Sloupce 3/7"
grapesjsbuilder.components.names.textSectionBlkLabel="Textová část"
grapesjsbuilder.components.names.gridItemsBlkLabel="Položky mřížky"
grapesjsbuilder.components.names.listItemsBlkLabel="Položky seznamu"

View File

@@ -0,0 +1,16 @@
grapesjsbuilder.sourceEditBtnLabel="Speichern"
grapesjsbuilder.sourceCancelBtnLabel="Abbrechen"
grapesjsbuilder.sourceEditModalTitle="Code bearbeiten"
grapesjsbuilder.sourceSyntaxError="Bitte korrigieren Sie den folgenden Fehler:"
grapesjsbuilder.deleteAssetConfirmText="Möchten Sie diese Datei wirklich löschen?"
grapesjsbuilder.categorySectionLabel="Abschnitte"
grapesjsbuilder.categoryBlockLabel="Blöcke"
grapesjsbuilder.dynamicContentBlockLabel="Dynamischer Inhalt"
grapesjsbuilder.dynamicContentBtnLabel="Speichern"
grapesjsbuilder.dynamicContentModalTitle="Dynamischen Inhalt bearbeiten"
grapesjsbuilder.assetManager.noAssets="Keine <b>Assets</b> hier, zum Hochladen ziehen"
grapesjsbuilder.buttonBlockLabel="Button"
grapesjsbuilder.components.names.twoColumnThirdSevens="2 Säulen 3/7"
grapesjsbuilder.components.names.textSectionBlkLabel="Text Abschnitt"
grapesjsbuilder.components.names.gridItemsBlkLabel="Raster Elemente"
grapesjsbuilder.components.names.listItemsBlkLabel="Elemente auflisten"

View File

@@ -0,0 +1,23 @@
grapesjsbuilder.sourceEditBtnLabel="Save"
grapesjsbuilder.sourceCancelBtnLabel="Cancel"
grapesjsbuilder.sourceEditModalTitle="Edit code"
grapesjsbuilder.sourceSyntaxError="Please fix the following error:"
grapesjsbuilder.deleteAssetConfirmText="Are you sure you wish to delete this file?"
grapesjsbuilder.categorySectionLabel="Sections"
grapesjsbuilder.categoryBlockLabel="Blocks"
grapesjsbuilder.dynamicContentBlockLabel="Dynamic Content"
grapesjsbuilder.dynamicContentBtnLabel="Save"
grapesjsbuilder.dynamicContentModalTitle="Edit Dynamic Content"
grapesjsbuilder.buttonBlockLabel="Button"
grapesjsbuilder.builder.warning.code_mode="By opening the builder you are leaving Code Mode. The builder might change your HTML code in unexpected ways. Only proceed if you know what you are doing."
grapesjsbuilder.components.names.oneColumn ="1 Column"
grapesjsbuilder.components.names.twoColumn ="2 Columns"
grapesjsbuilder.components.names.twoColumnThirdSevens="2 Columns 3/7"
grapesjsbuilder.components.names.threeColumn = "3 Columns"
grapesjsbuilder.components.names.textSectionBlkLabel="Text Section"
grapesjsbuilder.components.names.gridItemsBlkLabel="Grid Items"
grapesjsbuilder.components.names.listItemsBlkLabel="List Items"
grapesjsbuilder.panelsViewsCommandModalTitleError="Could not request your action"
grapesjsbuilder.panelsViewsButtonsApplyTitle="Apply changes"
grapesjsbuilder.buttons.buttonPreview.title="Preview"
grapesjsbuilder.buttons.buttonPreview.titleDisabled="Apply your changes to preview"

View File

@@ -0,0 +1,15 @@
grapesjsbuilder.sourceEditBtnLabel="Enregistrer"
grapesjsbuilder.sourceCancelBtnLabel="Annuler"
grapesjsbuilder.sourceEditModalTitle="Éditer le code"
grapesjsbuilder.deleteAssetConfirmText="Êtes-vous sûr de vouloir supprimer ce fichier ?"
grapesjsbuilder.categorySectionLabel="Sections"
grapesjsbuilder.categoryBlockLabel="Blocs"
grapesjsbuilder.dynamicContentBlockLabel="Contenu dynamique"
grapesjsbuilder.dynamicContentBtnLabel="Valider"
grapesjsbuilder.dynamicContentModalTitle="Éditer le contenu dynamique"
grapesjsbuilder.assetManager.noAssets="Pas <b>d'images</b>, déposez des fichiers ici pour en ajouter"
grapesjsbuilder.buttonBlockLabel="Bouton"
grapesjsbuilder.components.names.twoColumnThirdSevens="2 Colonnes 3/7"
grapesjsbuilder.components.names.textSectionBlkLabel="Section de texte"
grapesjsbuilder.components.names.gridItemsBlkLabel="Éléments de la grille"
grapesjsbuilder.components.names.listItemsBlkLabel="Éléments de liste"

View File

@@ -0,0 +1,15 @@
grapesjsbuilder.sourceEditBtnLabel="Editar"
grapesjsbuilder.sourceCancelBtnLabel="Cancelar"
grapesjsbuilder.sourceEditModalTitle="Editar HTML"
grapesjsbuilder.deleteAssetConfirmText="Tem certeza que deseja remover este arquivo?"
grapesjsbuilder.categorySectionLabel="Seções"
grapesjsbuilder.categoryBlockLabel="Blocos"
grapesjsbuilder.dynamicContentBlockLabel="Conteúdo Dinâmico"
grapesjsbuilder.dynamicContentBtnLabel="Salvar"
grapesjsbuilder.dynamicContentModalTitle="Alterar Conteúdo Dinâmico"
grapesjsbuilder.assetManager.noAssets="Nenhum <b>assets</b> aqui, arraste para fazer Upload"
grapesjsbuilder.buttonBlockLabel="Botón"
grapesjsbuilder.components.names.twoColumnThirdSevens="2 Colunas 3/7"
grapesjsbuilder.components.names.textSectionBlkLabel="Seção de Texto"
grapesjsbuilder.components.names.gridItemsBlkLabel="Itens da grade"
grapesjsbuilder.components.names.listItemsBlkLabel="Itens da lista"

View File

@@ -0,0 +1,37 @@
{
"name": "mautic/grapes-js-builder-bundle",
"description": "GrapesJS Builder with MJML support for Mautic",
"type": "mautic-plugin",
"keywords": [
"mautic",
"plugin",
"integration"
],
"extra": {
"install-directory-name": "GrapesJsBuilderBundle"
},
"require-dev": {
"phpstan/phpstan": "^0.11.12",
"symplify/easy-coding-standard": "^6.0"
},
"scripts": {
"test": [
"@phpunit",
"@fixcs",
"@phpstan"
],
"quicktest": [
"@unit"
],
"phpunit": "../../bin/phpunit -d memory_limit=2048M --bootstrap ../../vendor/autoload.php --configuration phpunit.xml --fail-on-warning --testsuite=all",
"unit": "../../bin/phpunit -d memory_limit=2048M --bootstrap ../../vendor/autoload.php --configuration phpunit.xml --fail-on-warning --testsuite=unit",
"coverage": "../../bin/phpunit -d memory_limit=2048M --bootstrap ../../vendor/autoload.php --configuration phpunit.xml --fail-on-warning --testsuite=all --coverage-text --coverage-html=Tests/Coverage",
"phpstan": "vendor/bin/phpstan analyse --autoload-file=../../vendor/autoload.php --level=max Config Connection Entity Form Integration Migrations Sync Tests",
"csfixer": "vendor/bin/ecs check .",
"fixcs": "vendor/bin/ecs check . --fix"
},
"minimum-stability": "dev",
"require": {
"mautic/core-lib": "^7.0"
}
}

View File

@@ -0,0 +1,28 @@
imports:
- { resource: 'vendor/symplify/easy-coding-standard/config/set/clean-code.yaml' }
- { resource: 'vendor/symplify/easy-coding-standard/config/set/php71.yaml' }
- { resource: 'vendor/symplify/easy-coding-standard/config/set/symfony.yaml' }
services:
PhpCsFixer\Fixer\Operator\BinaryOperatorSpacesFixer:
align_equals: true
align_double_arrow: true
parameters:
exclude_checkers:
- 'PhpCsFixer\Fixer\Operator\NotOperatorWithSuccessorSpaceFixer'
- 'Symplify\CodingStandard\Fixer\Commenting\RemoveUselessDocBlockFixer'
- 'PhpCsFixer\Fixer\Import\OrderedImportsFixer'
exclude_files:
- 'vendor/*'
skip:
PhpCsFixer\Fixer\ArraNotation\TrailingCommaInMultilineArrayFixer:
- 'Config/config.php' # Forces us to do not use full path class names
SlevomatCodingStandard\Sniffs\TypeHints\TypeHintDeclarationSniff.MissingParameterTypeHint:
- 'Tests/*'
SlevomatCodingStandard\Sniffs\TypeHints\TypeHintDeclarationSniff.MissingPropertyTypeHint:
- 'Tests/*'
SlevomatCodingStandard\Sniffs\Classes\UnusedPrivateElementsSniff.UnusedMethod:
- 'Connection/Client.php'
SlevomatCodingStandard\Sniffs\Classes\UnusedPrivateElementsSniff.WriteOnlyProperty:
- 'Connection/Client.php'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
{
"name": "grapesjsbuilderbundle",
"version": "1.0.0",
"description": "⚠️ This Plugin is still Beta! It works great already and we're developing it actively! Please use it and report everything inside the \"Issues\" here in Github. ⚠️",
"test": "Assets/library/js/dist/builder.js",
"targets": {
"test": {
"outputFormat": "global",
"distDir": "Assets/library/js/dist",
"publicUrl": "./",
"includeNodeModules": true
}
},
"@parcel/transformer-css": {
"errorRecovery": true
},
"scripts": {
"build": "parcel build Assets/library/js/builder.js --dist-dir Assets/library/js/dist --public-url ./ --no-source-maps",
"build-dev": "NODE_ENV=development parcel build Assets/library/js/builder.js --dist-dir Assets/library/js/dist --public-url ./ --no-optimize",
"remove-build": "rm -rf ./Assets/library/js/dist/* && rm -rf .parcel-cache/*",
"rebuild": "npm run remove-build && npm run build",
"rebuild-dev": "npm run remove-build && npm run build-dev",
"dev": "NODE_ENV=development parcel watch Assets/library/js/* --dist-dir Assets/library/js/dist --public-url /plugins/GrapesJsBuilderBundle/Assets/library/js/dist --hmr-hostname localhost",
"lint": "eslint Assets/library/js/",
"prettier": "node_modules/.bin/prettier -w Assets/library/js/",
"prettier-check": "node_modules/.bin/prettier -c Assets/library/js/",
"start-helloWorld": "parcel Demo/helloWorld/index.html",
"start-mautic-full": "cp -r Demo/data dist && parcel Demo/mautic/full.html",
"start-mautic": "cp -r Demo/data dist && parcel Demo/mautic/index.html",
"update-mautic-preset": "rm -r node_modules/grapesjs-preset-mautic && npm install mautic/grapesjs-preset-mautic#main"
},
"repository": {
"type": "git",
"url": "git+https://github.com/mautic/plugin-grapesjs-builder.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/mautic/plugin-grapesjs-builder/issues"
},
"homepage": "https://github.com/mautic/plugin-grapesjs-builder#readme",
"engines": {
"npm": ">=8.0.0",
"node": ">=16.0.0"
},
"dependencies": {
"grapesjs": "^0.22.4",
"grapesjs-blocks-basic": "^1.0.2",
"grapesjs-component-countdown": "^1.0.2",
"grapesjs-custom-code": "^1.0.2",
"grapesjs-mjml": "^1.0.6",
"grapesjs-navbar": "^1.0.2",
"grapesjs-parser-postcss": "^1.0.3",
"grapesjs-plugin-export": "^1.0.12",
"grapesjs-plugin-forms": "^2.0.6",
"grapesjs-preset-newsletter": "^1.0.2",
"grapesjs-preset-webpage": "^1.0.3",
"grapesjs-style-bg": "^2.0.2",
"grapesjs-tabs": "^1.0.6",
"grapesjs-tooltip": "^0.1.8",
"grapesjs-touch": "^0.1.1",
"grapesjs-tui-image-editor": "^1.0.2",
"grapesjs-typed": "^2.0.1"
},
"devDependencies": {
"@babel/cli": "^7.16.8",
"@babel/core": "^7.16.7",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-transform-runtime": "^7.16.8",
"babel-eslint": "^10.1.0",
"buffer": "^6.0.3",
"eslint": "^8.7.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"grapesjs-preset-mautic": "github:mautic/grapesjs-preset-mautic#5.2",
"parcel": "^2.11.0",
"prettier": "^2.5.1",
"process": "^0.11.10",
"sass": "^1.48.0"
}
}

View File

@@ -0,0 +1,3 @@
parameters:
ignoreErrors:
- '#.*|PHPUnit_Framework_MockObject_MockObject given$#'

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- http://www.phpunit.de/manual/current/en/appendixes.configuration.html -->
<phpunit
backupGlobals = "false"
backupStaticAttributes = "false"
colors = "true"
convertErrorsToExceptions = "true"
convertNoticesToExceptions = "true"
convertWarningsToExceptions = "true"
processIsolation = "false"
stopOnFailure = "false"
syntaxCheck = "false"
bootstrap = "autoload.php" >
<testsuites>
<testsuite name="unit">
<directory>Tests/Unit</directory>
</testsuite>
<testsuite name="functional">
<directory>Tests/Functional</directory>
</testsuite>
<testsuite name="all">
<directory>Tests/Unit</directory>
<directory>Tests/Functional</directory>
</testsuite>
</testsuites>
<php>
<server name="KERNEL_DIR" value="../../app" />
</php>
<filter>
<whitelist>
<directory>*</directory>
<exclude>
<directory>Assets</directory>
<directory>Config</directory>
<directory>Tests</directory>
<directory>Translations</directory>
<directory>Views</directory>
<directory>vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>

View File

@@ -0,0 +1,26 @@
# Workflow name:
name: Close Pull Requests
# Workflow triggers:
on:
pull_request_target:
types: [opened]
# Workflow jobs:
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thank you for submitting a pull request. :raised_hands:
We greatly appreciate your willingness to submit a contribution. However, we are not accepting pull requests against this repository, as all development happens on the [main project repository](https://github.com/mautic/mautic).
We kindly request that you submit this pull request against the [respective directory](https://github.com/mautic/mautic/blob/head/plugins/MauticClearbitBundle) of the main repository where we'll review and provide feedback. If this is your first Mautic contribution, be sure to read the [contributing guide](https://github.com/mautic/mautic/blob/4.x/.github/CONTRIBUTING.md) which provides guidelines and instructions for submitting contributions.
Thank you again, and we look forward to receiving your contribution! :smiley:
Best,
The Mautic team

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -0,0 +1,61 @@
<?php
return [
'name' => 'Clearbit',
'description' => 'Enables integration with Clearbit for contact and company lookup',
'version' => '1.0',
'author' => 'Werner Garcia',
'routes' => [
'public' => [
'mautic_plugin_clearbit_index' => [
'path' => '/clearbit/callback',
'controller' => 'MauticPlugin\MauticClearbitBundle\Controller\PublicController::callbackAction',
],
],
'main' => [
'mautic_plugin_clearbit_action' => [
'path' => '/clearbit/{objectAction}/{objectId}',
'controller' => 'MauticPlugin\MauticClearbitBundle\Controller\ClearbitController::executeAction',
],
],
],
'services' => [
'others' => [
'mautic.plugin.clearbit.lookup_helper' => [
'class' => MauticPlugin\MauticClearbitBundle\Helper\LookupHelper::class,
'arguments' => [
'mautic.helper.integration',
'mautic.helper.user',
'monolog.logger.mautic',
'mautic.lead.model.lead',
'mautic.lead.model.company',
],
],
],
'integrations' => [
'mautic.integration.clearbit' => [
'class' => MauticPlugin\MauticClearbitBundle\Integration\ClearbitIntegration::class,
'arguments' => [
'event_dispatcher',
'mautic.helper.cache_storage',
'doctrine.orm.entity_manager',
'request_stack',
'router',
'translator',
'monolog.logger.mautic',
'mautic.helper.encryption',
'mautic.lead.model.lead',
'mautic.lead.model.company',
'mautic.helper.paths',
'mautic.core.model.notification',
'mautic.lead.model.field',
'mautic.plugin.model.integration_entity',
'mautic.lead.model.dnc',
'mautic.lead.field.fields_with_unique_identifier',
],
],
],
],
];

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
'Services',
];
$services->load('MauticPlugin\\MauticClearbitBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
};

View File

@@ -0,0 +1,520 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Controller;
use Mautic\FormBundle\Controller\FormController;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use MauticPlugin\MauticClearbitBundle\Form\Type\BatchLookupType;
use MauticPlugin\MauticClearbitBundle\Form\Type\LookupType;
use MauticPlugin\MauticClearbitBundle\Helper\LookupHelper;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class ClearbitController extends FormController
{
/**
* @param string $objectId
*
* @return JsonResponse
*
* @throws \InvalidArgumentException
*/
public function lookupPersonAction(Request $request, LookupHelper $lookupHelper, $objectId = '')
{
if ('POST' === $request->getMethod()) {
$data = $request->request->all()['clearbit_lookup'] ?? [];
$objectId = $data['objectId'];
}
/** @var \Mautic\LeadBundle\Model\LeadModel $model */
$model = $this->getModel('lead');
$lead = $model->getEntity($objectId);
if (!$this->security->hasEntityAccess(
'lead:leads:editown',
'lead:leads:editother',
$lead->getPermissionUser()
)
) {
$this->addFlashMessage(
$this->translator->trans('mautic.plugin.clearbit.forbidden'),
[],
'error'
);
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
if ('GET' === $request->getMethod()) {
$route = $this->generateUrl(
'mautic_plugin_clearbit_action',
[
'objectAction' => 'lookupPerson',
]
);
return $this->delegateView(
[
'viewParameters' => [
'form' => $this->createForm(
LookupType::class,
[
'objectId' => $objectId,
],
[
'action' => $route,
]
)->createView(),
'lookupItem' => $lead->getEmail(),
],
'contentTemplate' => '@MauticClearbit/Clearbit/lookup.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_contact_index',
'mauticContent' => 'lead',
'route' => $route,
],
]
);
} else {
if ('POST' === $request->getMethod()) {
try {
$lookupHelper->lookupContact($lead, array_key_exists('notify', $data));
$this->addFlashMessage(
'mautic.lead.batch_leads_affected',
[
'%count%' => 1,
]
);
} catch (\Exception $ex) {
$this->addFlashMessage(
$ex->getMessage(),
[],
'error'
);
}
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
}
return new Response('Bad Request', 400);
}
/**
* @return JsonResponse
*
* @throws \InvalidArgumentException
*/
public function batchLookupPersonAction(Request $request, LookupHelper $lookupHelper)
{
/** @var \Mautic\LeadBundle\Model\LeadModel $model */
$model = $this->getModel('lead');
if ('GET' === $request->getMethod()) {
$data = $request->query->all()['clearbit_batch_lookup'] ?? [];
} else {
$data = $request->request->all()['clearbit_batch_lookup'] ?? [];
}
$entities = [];
if (array_key_exists('ids', $data)) {
$ids = $data['ids'];
if (!is_array($ids)) {
$ids = json_decode($ids, true);
}
if (is_array($ids) && count($ids)) {
$entities = $model->getEntities(
[
'filter' => [
'force' => [
[
'column' => 'l.id',
'expr' => 'in',
'value' => $ids,
],
],
],
'ignore_paginator' => true,
]
);
}
}
$lookupEmails = [];
if ($count = count($entities)) {
/** @var Lead $lead */
foreach ($entities as $lead) {
if ($this->security->hasEntityAccess(
'lead:leads:editown',
'lead:leads:editother',
$lead->getPermissionUser()
)
&& $lead->getEmail()
) {
$lookupEmails[$lead->getId()] = $lead->getEmail();
}
}
$count = count($lookupEmails);
}
if (0 === $count) {
$this->addFlashMessage(
$this->translator->trans('mautic.plugin.clearbit.empty'),
[],
'error'
);
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
} else {
if ($count > 20) {
$this->addFlashMessage(
$this->translator->trans('mautic.plugin.clearbit.toomany'),
[],
'error'
);
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
}
if ('GET' === $request->getMethod()) {
$route = $this->generateUrl(
'mautic_plugin_clearbit_action',
[
'objectAction' => 'batchLookupPerson',
]
);
return $this->delegateView(
[
'viewParameters' => [
'form' => $this->createForm(
BatchLookupType::class,
[],
[
'action' => $route,
]
)->createView(),
'lookupItems' => array_values($lookupEmails),
],
'contentTemplate' => '@MauticClearbit/Clearbit/batchLookup.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_contact_index',
'mauticContent' => 'leadBatch',
'route' => $route,
],
]
);
} else {
if ('POST' === $request->getMethod()) {
$notify = array_key_exists('notify', $data);
foreach ($lookupEmails as $id => $lookupEmail) {
if ($lead = $model->getEntity($id)) {
try {
$lookupHelper->lookupContact($lead, $notify);
} catch (\Exception $ex) {
$this->addFlashMessage(
$ex->getMessage(),
[],
'error'
);
--$count;
}
}
}
if ($count) {
$this->addFlashMessage(
'mautic.lead.batch_leads_affected',
[
'%count%' => $count,
]
);
}
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
}
return new Response('Bad Request', 400);
}
/***************** COMPANY ***********************/
/**
* @param string $objectId
*
* @return JsonResponse
*
* @throws \InvalidArgumentException
*/
public function lookupCompanyAction(Request $request, LookupHelper $lookupHelper, $objectId = '')
{
if ('POST' === $request->getMethod()) {
$data = $request->request->all()['clearbit_lookup'] ?? [];
$objectId = $data['objectId'];
}
/** @var \Mautic\LeadBundle\Model\CompanyModel $model */
$model = $this->getModel('lead.company');
/** @var Company $company */
$company = $model->getEntity($objectId);
if ('GET' === $request->getMethod()) {
$route = $this->generateUrl(
'mautic_plugin_clearbit_action',
[
'objectAction' => 'lookupCompany',
]
);
$website = $company->getFieldValue('companywebsite');
if (!$website) {
$this->addFlashMessage(
$this->translator->trans('mautic.plugin.clearbit.compempty'),
[],
'error'
);
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
$parse = parse_url($website);
return $this->delegateView(
[
'viewParameters' => [
'form' => $this->createForm(
LookupType::class,
[
'objectId' => $objectId,
],
[
'action' => $route,
]
)->createView(),
'lookupItem' => $parse['host'],
],
'contentTemplate' => '@MauticClearbit/Clearbit/lookup.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_company_index',
'mauticContent' => 'company',
'route' => $route,
],
]
);
} else {
if ('POST' === $request->getMethod()) {
try {
$lookupHelper->lookupCompany($company, array_key_exists('notify', $data));
$this->addFlashMessage(
'mautic.company.batch_companies_affected',
[
'%count%' => 1,
]
);
} catch (\Exception $ex) {
$this->addFlashMessage(
$ex->getMessage(),
[],
'error'
);
}
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
}
return new Response('Bad Request', 400);
}
/**
* @return JsonResponse
*
* @throws \InvalidArgumentException
*/
public function batchLookupCompanyAction(Request $request, LookupHelper $lookupHelper)
{
/** @var \Mautic\LeadBundle\Model\CompanyModel $model */
$model = $this->getModel('lead.company');
if ('GET' === $request->getMethod()) {
$data = $request->query->all()['clearbit_batch_lookup'] ?? [];
} else {
$data = $request->request->all()['clearbit_batch_lookup'] ?? [];
}
$entities = [];
if (array_key_exists('ids', $data)) {
$ids = $data['ids'];
if (!is_array($ids)) {
$ids = json_decode($ids, true);
}
if (is_array($ids) && count($ids)) {
$entities = $model->getEntities(
[
'filter' => [
'force' => [
[
'column' => 'comp.id',
'expr' => 'in',
'value' => $ids,
],
],
],
'ignore_paginator' => true,
]
);
}
}
$lookupWebsites = [];
if ($count = count($entities)) {
/** @var Company $company */
foreach ($entities as $company) {
if ($company->getFieldValue('companywebsite')) {
$website = $company->getFieldValue('companywebsite');
$parse = parse_url($website);
if (!isset($parse['host'])) {
continue;
}
$lookupWebsites[$company->getId()] = $parse['host'];
}
}
$count = count($lookupWebsites);
}
if (0 === $count) {
$this->addFlashMessage(
$this->translator->trans('mautic.plugin.clearbit.compempty'),
[],
'error'
);
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
} else {
if ($count > 20) {
$this->addFlashMessage(
$this->translator->trans('mautic.plugin.clearbit.comptoomany'),
[],
'error'
);
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
}
if ('GET' === $request->getMethod()) {
$route = $this->generateUrl(
'mautic_plugin_clearbit_action',
[
'objectAction' => 'batchLookupCompany',
]
);
return $this->delegateView(
[
'viewParameters' => [
'form' => $this->createForm(
BatchLookupType::class,
[],
[
'action' => $route,
]
)->createView(),
'lookupItems' => array_values($lookupWebsites),
],
'contentTemplate' => '@MauticClearbit/Clearbit/batchLookup.html.twig',
'passthroughVars' => [
'activeLink' => '#mautic_company_index',
'mauticContent' => 'companyBatch',
'route' => $route,
],
]
);
} else {
if ('POST' === $request->getMethod()) {
$notify = array_key_exists('notify', $data);
foreach ($lookupWebsites as $id => $lookupWebsite) {
if ($company = $model->getEntity($id)) {
try {
$lookupHelper->lookupCompany($company, $notify);
} catch (\Exception $ex) {
$this->addFlashMessage(
$ex->getMessage(),
[],
'error'
);
--$count;
}
}
}
if ($count) {
$this->addFlashMessage(
'mautic.company.batch_companies_affected',
[
'%count%' => $count,
]
);
}
return new JsonResponse(
[
'closeModal' => true,
'flashes' => $this->getFlashContent(),
]
);
}
}
return new Response('Bad Request', 400);
}
}

View File

@@ -0,0 +1,283 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Controller;
use Mautic\FormBundle\Controller\FormController;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\UserBundle\Entity\User;
use Mautic\UserBundle\Model\UserModel;
use MauticPlugin\MauticClearbitBundle\Helper\LookupHelper;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class PublicController extends FormController
{
/**
* Write a notification.
*
* @param string $message Message of the notification
* @param string $header Header for message
* @param string $iconClass CSS class for the icon (e.g. ri-eye-line)
* @param User|null $user User object; defaults to current user
*/
public function addNewNotification($message, $header, $iconClass, User $user): void
{
/** @var \Mautic\CoreBundle\Model\NotificationModel $notificationModel */
$notificationModel = $this->getModel('core.notification');
$notificationModel->addNotification($message, 'FullContact', false, $header, $iconClass, null, $user);
}
/**
* @throws \InvalidArgumentException
*/
public function callbackAction(Request $request, LoggerInterface $mauticLogger, LookupHelper $lookupHelper): Response
{
if (!$request->request->has('body') || !$request->request->has('id')
|| !$request->request->has('type')
|| !$request->request->has('status')
|| 200 !== $request->request->get('status')
) {
$mauticLogger->log('error', 'ERROR on Clearbit callback: Malformed request variables: '.json_encode($request->request->all(), JSON_PRETTY_PRINT));
return new Response('ERROR');
}
/** @var array $result */
$result = $request->request->all()['body'] ?? [];
$oid = $request->request->get('id');
$validatedRequest = $lookupHelper->validateRequest($oid, $request->request->get('type'));
if (!$validatedRequest || !is_array($result)) {
$mauticLogger->log('error', 'ERROR on Clearbit callback: Wrong body or id in request: id='.$oid.' body='.json_encode($result, JSON_PRETTY_PRINT));
return new Response('ERROR');
}
$notify = $validatedRequest['notify'];
try {
if ('person' === $request->request->get('type')) {
/** @var \Mautic\LeadBundle\Model\LeadModel $model */
$model = $this->getModel('lead');
/** @var Lead $lead */
$lead = $validatedRequest['entity'];
$currFields = $lead->getFields(true);
$mauticLogger->log('debug', 'CURRFIELDS: '.var_export($currFields, true));
$loc = [];
if (array_key_exists('geo', $result)) {
$loc = $result['geo'];
}
$data = [];
foreach ([
'facebook' => 'http://www.facebook.com/',
'linkedin' => 'http://www.linkedin.com/',
'twitter' => 'http://www.twitter.com/',
] as $p => $u) {
foreach ($result as $type => $socialProfile) {
if ($type === $p && empty($currFields[$p]['value'])) {
$data[$p] = (array_key_exists('handle', $socialProfile) && $socialProfile['handle']) ? $u.$socialProfile['handle'] : '';
break;
}
}
}
if (array_key_exists('name', $result)
&& array_key_exists(
'familyName',
$result['name']
)
&& empty($currFields['lastname']['value'])
) {
$data['lastname'] = $result['name']['familyName'];
}
if (array_key_exists('name', $result)
&& array_key_exists(
'givenName',
$result['name']
)
&& empty($currFields['firstname']['value'])
) {
$data['firstname'] = $result['name']['givenName'];
}
if (array_key_exists('site', $result) && empty($currFields['website']['value'])) {
$data['website'] = $result['site'];
}
if (array_key_exists('employment', $result)
&& array_key_exists(
'name',
$result['employment']
)
&& empty($currFields['company']['value'])
) {
$data['company'] = $result['employment']['name'];
}
if (array_key_exists('employment', $result)
&& array_key_exists(
'title',
$result['employment']
)
&& empty($currFields['position']['value'])
) {
$data['position'] = $result['employment']['title'];
}
if (array_key_exists('city', $loc) && empty($currFields['city']['value'])) {
$data['city'] = $loc['city'];
}
if (array_key_exists('state', $loc) && empty($currFields['state']['value'])) {
$data['state'] = $loc['state'];
}
if (array_key_exists('country', $loc) && empty($currFields['country']['value'])) {
$data['country'] = $loc['country'];
}
$mauticLogger->log('debug', 'SETTING FIELDS: '.print_r($data, true));
// Unset the nonce so that it's not used again
$socialCache = $lead->getSocialCache();
unset($socialCache['clearbit']['nonce']);
$lead->setSocialCache($socialCache);
$model->setFieldValues($lead, $data);
$model->saveEntity($lead);
if ($notify && (!isset($lead->imported) || !$lead->imported)) {
/** @var UserModel $userModel */
$userModel = $this->getModel('user');
if ($user = $userModel->getEntity($notify)) {
$this->addNewNotification(
sprintf($this->translator->trans('mautic.plugin.clearbit.contact_retrieved'), $lead->getEmail()),
'Clearbit Plugin',
'ri-search-line',
$user
);
}
}
} else {
/****************** COMPANY STUFF *********************/
if ('company' === $request->request->get('type')) {
/** @var \Mautic\LeadBundle\Model\CompanyModel $model */
$model = $this->getModel('lead.company');
/** @var Company $company */
$company = $validatedRequest['entity'];
$currFields = $company->getFields(true);
$loc = [];
if (array_key_exists('geo', $result)) {
$loc = $result['geo'];
}
$data = [];
if (array_key_exists('streetNumber', $loc)
&& array_key_exists(
'streetName',
$loc
)
&& empty($currFields['companyaddress1']['value'])
) {
$data['companyaddress1'] = $loc['streetNumber'].' '.$loc['streetName'];
}
if (array_key_exists('city', $loc) && empty($currFields['companycity']['value'])) {
$data['companycity'] = $loc['city'];
}
if (array_key_exists('metrics', $result)
&& array_key_exists(
'employees',
$result['metrics']
)
&& empty($currFields['companynumber_of_employees']['value'])
) {
$data['companynumber_of_employees'] = $result['metrics']['employees'];
}
if (array_key_exists('description', $result) && empty($currFields['companydescription']['value'])) {
$data['companydescription'] = $result['description'];
}
if (array_key_exists('phone', $result) && empty($currFields['companyphone']['value'])) {
$data['companyphone'] = $result['phone'];
}
if (array_key_exists('site', $result)
&& array_key_exists(
'emailAddresses',
$result['site']
)
&& count($result['site']['emailAddresses'])
&& empty($currFields['companyemail']['value'])
) {
$data['companyemail'] = $result['site']['emailAddresses'][0];
}
if (array_key_exists('country', $loc) && empty($currFields['companycountry']['value'])) {
$data['companycountry'] = $loc['country'];
}
if (array_key_exists('state', $loc) && empty($currFields['companystate']['value'])) {
$data['companystate'] = $loc['state'];
}
$mauticLogger->log('debug', 'SETTING FIELDS: '.print_r($data, true));
// Unset the nonce so that it's not used again
$socialCache = $company->getSocialCache();
unset($socialCache['clearbit']['nonce']);
$company->setSocialCache($socialCache);
$model->setFieldValues($company, $data);
$model->saveEntity($company);
if ($notify) {
/** @var UserModel $userModel */
$userModel = $this->getModel('user');
if ($user = $userModel->getEntity($notify)) {
$this->addNewNotification(
sprintf($this->translator->trans('mautic.plugin.clearbit.company_retrieved'), $company->getName()),
'Clearbit Plugin',
'ri-search-line',
$user
);
}
}
}
}
} catch (\Exception $ex) {
$mauticLogger->log('error', 'ERROR on Clearbit callback: '.$ex->getMessage());
try {
if ($notify) {
/** @var UserModel $userModel */
$userModel = $this->getModel('user');
if ($user = $userModel->getEntity($notify)) {
$this->addNewNotification(
sprintf(
$this->translator->trans('mautic.plugin.clearbit.unable'),
$ex->getMessage()
),
'Clearbit Plugin',
'ri-error-warning-line',
$user
);
}
}
} catch (\Exception $ex2) {
$mauticLogger->log('error', 'Clearbit: '.$ex2->getMessage());
}
}
return new Response('OK');
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticClearbitBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticClearbitExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\EventListener;
use Mautic\CoreBundle\CoreEvents;
use Mautic\CoreBundle\Event\CustomButtonEvent;
use Mautic\CoreBundle\Twig\Helper\ButtonHelper;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use MauticPlugin\MauticClearbitBundle\Integration\ClearbitIntegration;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ButtonSubscriber implements EventSubscriberInterface
{
public function __construct(
private IntegrationHelper $helper,
private TranslatorInterface $translator,
private RouterInterface $router,
) {
}
public static function getSubscribedEvents(): array
{
return [
CoreEvents::VIEW_INJECT_CUSTOM_BUTTONS => ['injectViewButtons', 0],
];
}
public function injectViewButtons(CustomButtonEvent $event): void
{
/** @var ClearbitIntegration $myIntegration */
$myIntegration = $this->helper->getIntegrationObject('Clearbit');
if (false === $myIntegration || !$myIntegration->getIntegrationSettings()->getIsPublished()) {
return;
}
if (str_starts_with($event->getRoute(), 'mautic_contact_')) {
$event->addButton(
[
'attr' => [
'class' => 'btn btn-ghost btn-sm btn-nospin',
'data-toggle' => 'ajaxmodal',
'data-target' => '#MauticSharedModal',
'onclick' => 'this.href=\''.
$this->router->generate(
'mautic_plugin_clearbit_action',
['objectAction' => 'batchLookupPerson']
).
'?\' + mQuery.param({\'clearbit_batch_lookup\':{\'ids\':JSON.parse(Mautic.getCheckedListIds(false, true))}});return true;',
'data-header' => $this->translator->trans('mautic.plugin.clearbit.button.caption'),
],
'btnText' => $this->translator->trans('mautic.plugin.clearbit.button.caption'),
'iconClass' => 'ri-search-line',
],
ButtonHelper::LOCATION_BULK_ACTIONS
);
if ($event->getItem()) {
$lookupContactButton = [
'attr' => [
'data-toggle' => 'ajaxmodal',
'data-target' => '#MauticSharedModal',
'data-header' => $this->translator->trans(
'mautic.plugin.clearbit.lookup.header',
['%item%' => $event->getItem()->getEmail()]
),
'href' => $this->router->generate(
'mautic_plugin_clearbit_action',
['objectId' => $event->getItem()->getId(), 'objectAction' => 'lookupPerson']
),
],
'btnText' => $this->translator->trans('mautic.plugin.clearbit.button.caption'),
'iconClass' => 'ri-search-line',
];
$event->addButton(
$lookupContactButton,
ButtonHelper::LOCATION_PAGE_ACTIONS,
['mautic_contact_action', ['objectAction' => 'view']]
);
$event->addButton(
$lookupContactButton,
ButtonHelper::LOCATION_LIST_ACTIONS,
'mautic_contact_index'
);
}
} else {
if (str_starts_with($event->getRoute(), 'mautic_company_')) {
$event->addButton(
[
'attr' => [
'class' => 'btn btn-ghost btn-sm btn-nospin',
'data-toggle' => 'ajaxmodal',
'data-target' => '#MauticSharedModal',
'onclick' => 'this.href=\''.
$this->router->generate(
'mautic_plugin_clearbit_action',
['objectAction' => 'batchLookupCompany']
).
'?\' + mQuery.param({\'clearbit_batch_lookup\':{\'ids\':JSON.parse(Mautic.getCheckedListIds(false, true))}});return true;',
'data-header' => $this->translator->trans(
'mautic.plugin.clearbit.button.caption'
),
],
'btnText' => $this->translator->trans('mautic.plugin.clearbit.button.caption'),
'iconClass' => 'ri-search-line',
],
ButtonHelper::LOCATION_BULK_ACTIONS
);
if ($event->getItem()) {
$lookupCompanyButton = [
'attr' => [
'data-toggle' => 'ajaxmodal',
'data-target' => '#MauticSharedModal',
'data-header' => $this->translator->trans(
'mautic.plugin.clearbit.lookup.header',
['%item%' => $event->getItem()->getName()]
),
'href' => $this->router->generate(
'mautic_plugin_clearbit_action',
['objectId' => $event->getItem()->getId(), 'objectAction' => 'lookupCompany']
),
],
'btnText' => $this->translator->trans('mautic.plugin.clearbit.button.caption'),
'iconClass' => 'ri-search-line',
];
$event->addButton(
$lookupCompanyButton,
ButtonHelper::LOCATION_LIST_ACTIONS,
'mautic_company_index'
);
}
}
}
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\EventListener;
use Mautic\LeadBundle\Event\CompanyEvent;
use Mautic\LeadBundle\Event\LeadEvent;
use Mautic\LeadBundle\LeadEvents;
use MauticPlugin\MauticClearbitBundle\Helper\LookupHelper;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class LeadSubscriber implements EventSubscriberInterface
{
public function __construct(
private LookupHelper $lookupHelper,
) {
}
public static function getSubscribedEvents(): array
{
return [
LeadEvents::LEAD_POST_SAVE => ['leadPostSave', 0],
LeadEvents::COMPANY_POST_SAVE => ['companyPostSave', 0],
];
}
public function leadPostSave(LeadEvent $event): void
{
$this->lookupHelper->lookupContact($event->getLead(), true, true);
}
public function companyPostSave(CompanyEvent $event): void
{
$this->lookupHelper->lookupCompany($event->getCompany(), true, true);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class BatchLookupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('ids', HiddenType::class);
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_text' => false,
'save_text' => 'mautic.core.form.submit',
'cancel_onclick' => 'javascript:void(0);',
'cancel_attr' => [
'data-dismiss' => 'modal',
],
]
);
$builder->add(
'notify',
YesNoButtonGroupType::class,
[
'label' => 'mautic.plugin.clearbit.notify',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'data' => true,
'required' => false,
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function getBlockPrefix(): string
{
return 'clearbit_batch_lookup';
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Form\Type;
use Mautic\CoreBundle\Form\Type\FormButtonsType;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
/**
* @extends AbstractType<array<mixed>>
*/
class LookupType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add(
'objectId',
HiddenType::class,
[
'attr' => [
'value' => $options['data']['objectId'],
],
]
);
$builder->add(
'buttons',
FormButtonsType::class,
[
'apply_text' => false,
'save_text' => 'mautic.core.form.submit',
'cancel_onclick' => 'javascript:void(0);',
'cancel_attr' => [
'data-dismiss' => 'modal',
],
]
);
$builder->add(
'notify',
YesNoButtonGroupType::class,
[
'label' => 'mautic.plugin.clearbit.notify',
'label_attr' => ['class' => 'control-label'],
'attr' => [
'class' => 'form-control',
],
'data' => true,
'required' => false,
]
);
if (!empty($options['action'])) {
$builder->setAction($options['action']);
}
}
public function getBlockPrefix(): string
{
return 'clearbit_lookup';
}
}

View File

@@ -0,0 +1,179 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Helper;
use Mautic\CoreBundle\Helper\EncryptionHelper;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\LeadBundle\Entity\Company;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Model\CompanyModel;
use Mautic\LeadBundle\Model\LeadModel;
use Mautic\PluginBundle\Helper\IntegrationHelper;
use MauticPlugin\MauticClearbitBundle\Integration\ClearbitIntegration;
use MauticPlugin\MauticClearbitBundle\Services\Clearbit_Company;
use MauticPlugin\MauticClearbitBundle\Services\Clearbit_Person;
use Monolog\Logger;
class LookupHelper
{
/**
* @var bool|ClearbitIntegration
*/
protected $integration;
public function __construct(
IntegrationHelper $integrationHelper,
protected UserHelper $userHelper,
protected Logger $logger,
protected LeadModel $leadModel,
protected CompanyModel $companyModel,
) {
$this->integration = $integrationHelper->getIntegrationObject('Clearbit');
}
/**
* @param bool $notify
* @param bool $checkAuto
*/
public function lookupContact(Lead $lead, $notify = false, $checkAuto = false): void
{
if (!$lead->getEmail()) {
return;
}
/* @var Clearbit_Person $clearbit */
if ($clearbit = $this->getClearbit()) {
if (!$checkAuto || ($checkAuto && $this->integration->shouldAutoUpdate())) {
try {
[$cacheId, $webhookId, $cache] = $this->getCache($lead, $notify);
if (!array_key_exists($cacheId, $cache['clearbit'])) {
$clearbit->setWebhookId($webhookId);
$res = $clearbit->lookupByEmail($lead->getEmail());
// Prevent from filling up the cache
$cache['clearbit'] = [
$cacheId => serialize($res),
'nonce' => $cache['clearbit']['nonce'],
];
$lead->setSocialCache($cache);
if ($checkAuto) {
$this->leadModel->getRepository()->saveEntity($lead);
} else {
$this->leadModel->saveEntity($lead);
}
}
} catch (\Exception $ex) {
$this->logger->log('error', 'Error while using Clearbit to lookup '.$lead->getEmail().': '.$ex->getMessage());
}
}
}
}
/**
* @param bool $notify
* @param bool $checkAuto
*/
public function lookupCompany(Company $company, $notify = false, $checkAuto = false): void
{
if (!$website = $company->getFieldValue('companywebsite')) {
return;
}
/* @var Clearbit_Company $clearbit */
if ($clearbit = $this->getClearbit(false)) {
if (!$checkAuto || ($checkAuto && $this->integration->shouldAutoUpdate())) {
try {
$parse = parse_url($company->getFieldValue('companywebsite'));
[$cacheId, $webhookId, $cache] = $this->getCache($company, $notify);
if (isset($parse['host']) && !array_key_exists($cacheId, $cache['clearbit'])) {
/* @var Router $router */
$clearbit->setWebhookId($webhookId);
$res = $clearbit->lookupByDomain($parse['host']);
// Prevent from filling up the cache
$cache['clearbit'] = [
$cacheId => serialize($res),
'nonce' => $cache['clearbit']['nonce'],
];
$company->setSocialCache($cache);
if ($checkAuto) {
$this->companyModel->getRepository()->saveEntity($company);
} else {
$this->companyModel->saveEntity($company);
}
}
} catch (\Exception $ex) {
$this->logger->log('error', 'Error while using Clearbit to lookup '.$parse['host'].': '.$ex->getMessage());
}
}
}
}
public function validateRequest($oid, $type)
{
// prefix#entityId#hour#userId#nonce
[$w, $id, $hour, $uid, $nonce] = explode('#', $oid, 5);
$notify = (str_contains($w, '_notify') && $uid) ? $uid : false;
switch ($type) {
case 'person':
$entity = $this->leadModel->getEntity($id);
break;
case 'company':
$entity = $this->companyModel->getEntity($id);
break;
}
if ($entity) {
$socialCache = $entity->getSocialCache();
$cacheId = $w.'#'.$id.'#'.$hour;
if (isset($socialCache['clearbit'][$cacheId]) && !empty($socialCache['clearbit']['nonce']) && !empty($nonce)
&& $socialCache['clearbit']['nonce'] === $nonce
) {
return [
'notify' => $notify,
'entity' => $entity,
];
}
}
return false;
}
/**
* @param bool $person
*
* @return bool|Clearbit_Company|Clearbit_Person
*/
protected function getClearbit($person = true)
{
if (!$this->integration || !$this->integration->getIntegrationSettings()->getIsPublished()) {
return false;
}
// get api_key from plugin settings
$keys = $this->integration->getDecryptedApiKeys();
return ($person) ? new Clearbit_Person($keys['apikey']) : new Clearbit_Company($keys['apikey']);
}
protected function getCache($entity, $notify): array
{
/** @var User $user */
$user = $this->userHelper->getUser();
$nonce = substr(EncryptionHelper::generateKey(), 0, 16);
$cacheId = sprintf('clearbit%s#', $notify ? '_notify' : '').$entity->getId().'#'.gmdate('YmdH');
$webhookId = $cacheId.'#'.$user->getId().'#'.$nonce;
$cache = $entity->getSocialCache();
if (!isset($cache['clearbit'])) {
$cache['clearbit'] = [];
}
$cache['clearbit']['nonce'] = $nonce;
return [$cacheId, $webhookId, $cache];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Integration;
use Mautic\CoreBundle\Form\Type\YesNoButtonGroupType;
use Mautic\PluginBundle\Integration\AbstractIntegration;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class ClearbitIntegration extends AbstractIntegration
{
public function getName(): string
{
return 'Clearbit';
}
/**
* Return's authentication method such as oauth2, oauth1a, key, etc.
*/
public function getAuthenticationType(): string
{
return 'none';
}
/**
* Return array of key => label elements that will be converted to inputs to
* obtain from the user.
*
* @return array<string, string>
*/
public function getRequiredKeyFields(): array
{
// Do not rename field. clearbit.js depends on it
return [
'apikey' => 'mautic.integration.clearbit.apikey',
];
}
/**
* @param FormBuilder|Form $builder
* @param array $data
* @param string $formArea
*/
public function appendToForm(&$builder, $data, $formArea): void
{
if ('keys' === $formArea) {
$builder->add(
'auto_update',
YesNoButtonGroupType::class,
[
'label' => 'mautic.plugin.clearbit.auto_update',
'data' => isset($data['auto_update']) && (bool) $data['auto_update'],
'attr' => [
'tooltip' => 'mautic.plugin.clearbit.auto_update.tooltip',
],
]
);
}
}
public function shouldAutoUpdate(): bool
{
$featureSettings = $this->getKeys();
return isset($featureSettings['auto_update']) && (bool) $featureSettings['auto_update'];
}
/**
* @return string|array
*/
public function getFormNotes($section)
{
if ('custom' === $section) {
return [
'template' => '@MauticClearbit/Integration/form.html.twig',
'parameters' => [
'mauticUrl' => $this->router->generate(
'mautic_plugin_clearbit_index', [], UrlGeneratorInterface::ABSOLUTE_URL
),
],
];
}
return parent::getFormNotes($section);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace MauticPlugin\MauticClearbitBundle;
use Mautic\PluginBundle\Bundle\PluginBundleBase;
class MauticClearbitBundle extends PluginBundleBase
{
}

View File

@@ -0,0 +1,5 @@
# Mautic bundle for Clearbit plugin
## This plugin is managed centrally in https://github.com/mautic/mautic/blob/head/plugins/MauticClearbitBundle and this is a read-only mirror repository.
**📣 Please make PRs and issues against Mautic Core, not here!**

View File

@@ -0,0 +1,18 @@
<div class="alert alert-info">{{ 'mautic.plugin.clearbit.submit_items'|trans }}</div>
<div style="margin-top: 10px">
<ul class="list-group" style="max-height: 400px;overflow-y: auto">
{% for item in lookupItems %}
<li class="list-group-item">{{ item }}</li>
{% endfor %}
</ul>
</div>
<script>
(function () {
var ids = Mautic.getCheckedListIds(false, true);
if (mQuery('#clearbit_batch_lookup_ids').length) {
mQuery('#clearbit_batch_lookup_ids').val(ids);
}
})();
</script>
{{ form(form) }}

View File

@@ -0,0 +1,7 @@
<div class="alert alert-info">{{ 'mautic.plugin.clearbit.submit'|trans }}</div>
<div style="margin-top: 10px">
<ul class="list-group" style="max-height: 400px;overflow-y: auto">
<li class="list-group-item">{{ lookupItem }}</li>
</ul>
</div>
{{ form(form) }}

View File

@@ -0,0 +1,9 @@
<div class="well well-sm" style="margin-bottom:0 !important;">
<p>
{{ 'mautic.plugin.clearbit.webhook_info'|trans|purify }}
</p>
<div class="alert alert-warning">
{{ 'mautic.plugin.clearbit.public_info'|trans|purify }}
</div>
<input type="text" readonly="" onclick="this.setSelectionRange(0, this.value.length);" value="{{ mauticUrl }}" class="form-control">
</div>

View File

@@ -0,0 +1,132 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Services;
/**
* This class handles the actually HTTP request to the Clearbit endpoint.
*/
class Clearbit_Base
{
public const REQUEST_LATENCY = 0.2;
public const USER_AGENT = 'mautic/clearbit-php-0.1.0';
private \DateTime $_next_req_time;
protected $_baseUri = '';
protected $_resourceUri = '';
protected $_version = 'v2';
protected $_webhookId;
public $response_obj;
public $response_code;
public $response_json;
/**
* Slow down calls to the Clearbit API if needed.
*/
private function _wait_for_rate_limit(): void
{
$now = new \DateTime();
if ($this->_next_req_time->getTimestamp() > $now->getTimestamp()) {
$t = $this->_next_req_time->getTimestamp() - $now->getTimestamp();
sleep($t);
}
}
/**
* @param mixed[] $hdr
*/
private function _update_rate_limit($hdr): void
{
$remaining = (float) $hdr['X-RateLimit-Remaining'];
$reset = (float) $hdr['X-RateLimit-Reset'];
$spacing = $reset / (1.0 + $remaining);
$delay = $spacing - self::REQUEST_LATENCY;
$this->_next_req_time = new \DateTime('now + '.$delay.' seconds');
}
/**
* The base constructor Sets the API key available from here:
* https://dashboard.clearbit.com/keys.
*
* @param string $api_key
*/
public function __construct(
protected $api_key,
) {
$this->_next_req_time = new \DateTime('@0');
}
/**
* @param string $id
*
* @return object
*/
public function setWebhookId($id = null)
{
$this->_webhookId = $id;
return $this;
}
/**
* @param array $params
*
* @return object
*/
protected function _execute($params = [])
{
$this->_wait_for_rate_limit();
if ($this->_webhookId) {
$params['webhook_id'] = $this->_webhookId;
}
$fullUrl = $this->_baseUri.$this->_version.$this->_resourceUri.
'?'.http_build_query($params);
// open connection
$connection = curl_init($fullUrl);
curl_setopt($connection, CURLOPT_RETURNTRANSFER, true);
curl_setopt($connection, CURLOPT_USERAGENT, self::USER_AGENT);
curl_setopt($connection, CURLOPT_HEADER, true); // return HTTP headers with response
curl_setopt($connection, CURLOPT_HTTPHEADER, ['Authorization: Bearer '.$this->api_key]);
// execute request
$resp = curl_exec($connection);
[$response_headers, $this->response_json] = explode("\r\n\r\n", $resp, 2);
// $response_headers now has a string of the HTTP headers
// $response_json is the body of the HTTP response
$headers = [];
foreach (explode("\r\n", $response_headers) as $i => $line) {
if (0 === $i) {
$headers['http_code'] = $line;
} else {
[$key, $value] = explode(': ', $line);
$headers[$key] = $value;
}
}
$this->response_code = curl_getinfo($connection, CURLINFO_HTTP_CODE);
$this->response_obj = json_decode($this->response_json);
if (!in_array($this->response_code, [200, 201, 202], true)) {
throw new \Exception($this->response_obj->error->message);
} else {
if ('200' === $this->response_code) {
$this->_update_rate_limit($headers);
}
}
return $this->response_obj;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Services;
/**
* This class handles everything related to the Company lookup API.
*/
class Clearbit_Company extends Clearbit_Base
{
public function __construct($api_key)
{
parent::__construct($api_key);
$this->_baseUri = 'https://company.clearbit.com/';
$this->_resourceUri = '/companies/find';
}
public function lookupByDomain($search)
{
$this->_execute(['domain' => $search]);
return $this->response_obj;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace MauticPlugin\MauticClearbitBundle\Services;
/**
* This class handles everything related to the Person lookup API.
*/
class Clearbit_Person extends Clearbit_Base
{
protected $_resourceUri = '/people/find';
protected $_baseUri = 'https://person.clearbit.com/';
public function lookupByEmail($search)
{
$this->_execute(['email' => $search]);
return $this->response_obj;
}
}

View File

@@ -0,0 +1,21 @@
mautic.integration.clearbit.apikey="Clearbit API Key"
mautic.plugin.clearbit.button.caption="Lookup using Clearbit"
mautic.plugin.clearbit.lookup.header="Clearbit - Lookup information for %item%"
mautic.plugin.clearbit.test_api="Test API and get Stats"
mautic.plugin.clearbit.stats="Test Results"
mautic.plugin.clearbit.toomany="You can only lookup 20 contacts at once!"
mautic.plugin.clearbit.comptoomany="You can only lookup 20 companies at once!"
mautic.plugin.clearbit.empty="There are no contacts to lookup!"
mautic.plugin.clearbit.compempty="There are no company domains to lookup!<br/>(Company website is empty?)"
mautic.plugin.clearbit.forbidden="You don't have permissions to update this contact"
mautic.plugin.clearbit.compforbidden="You don't have permissions to update this company"
mautic.plugin.clearbit.auto_update="Automatically update on save?"
mautic.plugin.clearbit.auto_update.tooltip="WARNING: This could easily exhaust your quota of API calls per month."
mautic.plugin.clearbit.notify="Show a notification when the information has been received."
mautic.plugin.clearbit.contact_retrieved="The contact information for %s has been retrieved"
mautic.plugin.clearbit.company_retrieved="The company information for %s has been retrieved"
mautic.plugin.clearbit.unable="Unable to save the information: %s"
mautic.plugin.clearbit.webhook_info="For the plugin to work, you must use the following as the Webhook URL in your account settings on the <a href=\"https://dashboard.clearbit.com/account\" target=\"_blank\">Clearbit Dashboard</a>:"
mautic.plugin.clearbit.public_info="<strong>Warning!</strong> This must be a public accessible URL for the Webhook to work."
mautic.plugin.clearbit.submit="Click submit to lookup the information for:"
mautic.plugin.clearbit.submit_items="Click submit to lookup the information for the selected item(s)."

View File

@@ -0,0 +1,17 @@
{
"name": "mautic/plugin-clearbit",
"description": "Clearbit Plugin",
"type": "mautic-plugin",
"keywords": [
"mautic",
"plugin",
"integration"
],
"extra": {
"install-directory-name": "MauticClearbitBundle"
},
"minimum-stability": "dev",
"require": {
"mautic/core-lib": "^7.0"
}
}

View File

@@ -0,0 +1,26 @@
# Workflow name:
name: Close Pull Requests
# Workflow triggers:
on:
pull_request_target:
types: [opened]
# Workflow jobs:
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: superbrothers/close-pull-request@v3
with:
comment: |
Thank you for submitting a pull request. :raised_hands:
We greatly appreciate your willingness to submit a contribution. However, we are not accepting pull requests against this repository, as all development happens on the [main project repository](https://github.com/mautic/mautic).
We kindly request that you submit this pull request against the [respective directory](https://github.com/mautic/mautic/blob/head/plugins/MauticCloudStorageBundle) of the main repository where we'll review and provide feedback. If this is your first Mautic contribution, be sure to read the [contributing guide](https://github.com/mautic/mautic/blob/4.x/.github/CONTRIBUTING.md) which provides guidelines and instructions for submitting contributions.
Thank you again, and we look forward to receiving your contribution! :smiley:
Best,
The Mautic team

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,34 @@
<?php
return [
'name' => 'Cloud Storage',
'description' => 'Enables integrations with Mautic supported cloud storage services.',
'version' => '1.0',
'author' => 'Mautic',
'services' => [
'integrations' => [
'mautic.integration.amazons3' => [
'class' => MauticPlugin\MauticCloudStorageBundle\Integration\AmazonS3Integration::class,
'arguments' => [
'event_dispatcher',
'mautic.helper.cache_storage',
'doctrine.orm.entity_manager',
'request_stack',
'router',
'translator',
'monolog.logger.mautic',
'mautic.helper.encryption',
'mautic.lead.model.lead',
'mautic.lead.model.company',
'mautic.helper.paths',
'mautic.core.model.notification',
'mautic.lead.model.field',
'mautic.plugin.model.integration_entity',
'mautic.lead.model.dnc',
'mautic.lead.field.fields_with_unique_identifier',
],
],
],
],
];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure()
->public();
$excludes = [
];
$services->load('MauticPlugin\\MauticCloudStorageBundle\\', '../')
->exclude('../{'.implode(',', array_merge(MauticCoreExtension::DEFAULT_EXCLUDES, $excludes)).'}');
};

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace MauticPlugin\MauticCloudStorageBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
class MauticCloudStorageExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MauticPlugin\MauticCloudStorageBundle\EventListener;
use Mautic\AssetBundle\AssetEvents;
use Mautic\AssetBundle\Event as Events;
use MauticPlugin\MauticCloudStorageBundle\Exception\InvalidCredentialConfigurationException;
use MauticPlugin\MauticCloudStorageBundle\Integration\CloudStorageIntegration;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class RemoteAssetBrowseSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
AssetEvents::ASSET_ON_REMOTE_BROWSE => ['onAssetRemoteBrowse', 0],
];
}
/**
* Fetches the connector for an event's integration.
*/
public function onAssetRemoteBrowse(Events\RemoteAssetBrowseEvent $event): void
{
/** @var CloudStorageIntegration $integration */
$integration = $event->getIntegration();
try {
$event->setAdapter($integration->getAdapter());
} catch (InvalidCredentialConfigurationException $e) {
$event->setFailed($e->getMessage());
}
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace MauticPlugin\MauticCloudStorageBundle\Exception;
class InvalidCredentialConfigurationException extends \RuntimeException
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace MauticPlugin\MauticCloudStorageBundle\Exception;
class NoFormNeededException extends \Exception
{
}

Some files were not shown because too many files have changed in this diff Show More