Merge branch 'transactional-mail-v2' into development
This commit is contained in:
commit
31442453ea
153 changed files with 10217 additions and 9007 deletions
2
.gitmodules
vendored
2
.gitmodules
vendored
|
@ -1,3 +1,3 @@
|
|||
[submodule "mvis/ivis-core"]
|
||||
path = mvis/ivis-core
|
||||
url = git@gitlab.d3s.mff.cuni.cz:evif/ivis-core.git
|
||||
url = https://gitlab.d3s.mff.cuni.cz/evif/ivis-core.git
|
||||
|
|
13
README.md
13
README.md
|
@ -1,6 +1,6 @@
|
|||
# Mailtrain v2 (beta)
|
||||
|
||||
[Mailtrain](http://mailtrain.org) is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+).
|
||||
Mailtrain is a self hosted newsletter application built on Node.js (v10+) and MySQL (v8+) or MariaDB (v10+).
|
||||
|
||||
![](https://mailtrain.org/mailtrain.png)
|
||||
|
||||
|
@ -176,18 +176,15 @@ To deploy Mailtrain with Docker, you need the following three dependencies insta
|
|||
|
||||
- [Docker](https://www.docker.com/)
|
||||
- [Docker Compose](https://docs.docker.com/compose/)
|
||||
- Git - Typically already present. If not, just use the package manager of your OS distribution to install it.
|
||||
|
||||
These are the steps to start Mailtrain via docker-compose:
|
||||
|
||||
1. Download Mailtrain using git
|
||||
1. Download Mailtrain's docker-compose build file
|
||||
```
|
||||
git clone https://github.com/Mailtrain-org/mailtrain.git
|
||||
cd mailtrain
|
||||
git checkout development
|
||||
curl -O https://raw.githubusercontent.com/Mailtrain-org/mailtrain/development/docker-compose.yml
|
||||
```
|
||||
|
||||
2. Deploy Mailtrain via docker-compose (in the root directory of the Mailtrain project). This will take quite some time when run for the first time. Subsequent executions will be fast.
|
||||
2. Deploy Mailtrain via docker-compose (in the directory to which you downloaded the `docker-compose.yml` file). This will take quite some time when run for the first time. Subsequent executions will be fast.
|
||||
```
|
||||
docker-compose up
|
||||
```
|
||||
|
@ -201,6 +198,8 @@ These are the steps to start Mailtrain via docker-compose:
|
|||
|
||||
4. Authenticate as `admin`:`test`
|
||||
|
||||
The instructions above use an automatically built Docker image on DockerHub (https://hub.docker.com/r/mailtrain/mailtrain). If you want to build the Docker image yourself (e.g. when doing development), use the `docker-compose-local-build.yml` located in the project's root directory.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
|
|
42
TODO.md
42
TODO.md
|
@ -1,21 +1,21 @@
|
|||
### Front page
|
||||
- Some dashboard
|
||||
|
||||
### Campaigns
|
||||
- List of sent RSS campaigns (?)
|
||||
|
||||
### Pull requests
|
||||
- Support ldaps:// - 5325f2ea7864ce5f42a9a6df3408af7ffbd32591
|
||||
- Support https - abd788d8f4d18b5a977226ba1224cba7f2b7fa9b
|
||||
- Support warn of failed login - 4bd1e994b27420ba366d9b0429e9014e5bf01f13
|
||||
- Add X-Mailer header option in settings to override or disable it - 44fe8882b876bdfd9990110496d16f819dc64ac3
|
||||
- Add custom unsubscribe option in a campaign - 68cb8384f7dfdbcaf2932293ec5a2f1ec0a1554e
|
||||
|
||||
### API
|
||||
- Add API extensions
|
||||
|
||||
### GDPR
|
||||
- Refuse editing subscriptions which have been anonymized
|
||||
- Add field to subscriptions which says till when the consent has been given
|
||||
- Provide a link (and merge tag) that will update the consent date to now
|
||||
- Add campaign trigger that triggers if the consent for specific subscription field is about to expire (i.e. it is greater than now - seconds)
|
||||
### Front page
|
||||
- Some dashboard
|
||||
|
||||
### Campaigns
|
||||
- List of sent RSS campaigns (?)
|
||||
|
||||
### Pull requests
|
||||
- Support ldaps:// - 5325f2ea7864ce5f42a9a6df3408af7ffbd32591
|
||||
- Support https - abd788d8f4d18b5a977226ba1224cba7f2b7fa9b
|
||||
- Support warn of failed login - 4bd1e994b27420ba366d9b0429e9014e5bf01f13
|
||||
- Add X-Mailer header option in settings to override or disable it - 44fe8882b876bdfd9990110496d16f819dc64ac3
|
||||
- Add custom unsubscribe option in a campaign - 68cb8384f7dfdbcaf2932293ec5a2f1ec0a1554e
|
||||
|
||||
### API
|
||||
- Add API extensions
|
||||
|
||||
### GDPR
|
||||
- Refuse editing subscriptions which have been anonymized
|
||||
- Add field to subscriptions which says till when the consent has been given
|
||||
- Provide a link (and merge tag) that will update the consent date to now
|
||||
- Add campaign trigger that triggers if the consent for specific subscription field is about to expire (i.e. it is greater than now - seconds)
|
||||
|
|
42
UPGRADE.md
42
UPGRADE.md
|
@ -1,21 +1,21 @@
|
|||
## Migration from Mailtrain v1 to Mailtrain v2
|
||||
|
||||
The migration should happen almost automatically. There are however the following caveats:
|
||||
|
||||
1. Structure of config files (under `config`) has changed at many places. Revisit the default config (`config/default.toml`)
|
||||
and update your configs accordingly.
|
||||
|
||||
2. Images uploaded in a template editor (Mosaico, Grapesjs, etc.) need to be manually moved to a new destination (under `client`).
|
||||
For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/static/mosaico`.
|
||||
|
||||
3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/static/mosaico/templates`.
|
||||
|
||||
4. Imports are not migrated. If you have any pending imports, complete them before migration to v2.
|
||||
|
||||
5. Zone MTA configuration endpoint (webhooks/zone-mta/sender-config) has changed. The send-configuration CID has to be
|
||||
part of the URL - e.g. webhooks/zone-mta/sender-config/system.
|
||||
|
||||
6. If there are lists that contain birthday or date fields that were created before
|
||||
commit `bc73a0df0cab9943d726bd12fc1c6f2ff1279aa7` (on Jan 3, 2018), they still have TIMESTAMP data type in DB instead
|
||||
of DATETIME. The problem was that that commit did not introduce migration from TIMESTAMP to DATETIME.
|
||||
Mailtrain v2 does this migration, however in some corner cases, this may shift the date by a day back or forth.
|
||||
## Migration from Mailtrain v1 to Mailtrain v2
|
||||
|
||||
The migration should happen almost automatically. There are however the following caveats:
|
||||
|
||||
1. Structure of config files (under `config`) has changed at many places. Revisit the default config (`config/default.toml`)
|
||||
and update your configs accordingly.
|
||||
|
||||
2. Images uploaded in a template editor (Mosaico, Grapesjs, etc.) need to be manually moved to a new destination (under `client`).
|
||||
For Mosaico, this means to move folders named by a number from `public/mosaico` to `client/static/mosaico`.
|
||||
|
||||
3. Directory for custom Mosaico templates has changed from `public/mosaico/templates` to `client/static/mosaico/templates`.
|
||||
|
||||
4. Imports are not migrated. If you have any pending imports, complete them before migration to v2.
|
||||
|
||||
5. Zone MTA configuration endpoint (webhooks/zone-mta/sender-config) has changed. The send-configuration CID has to be
|
||||
part of the URL - e.g. webhooks/zone-mta/sender-config/system.
|
||||
|
||||
6. If there are lists that contain birthday or date fields that were created before
|
||||
commit `bc73a0df0cab9943d726bd12fc1c6f2ff1279aa7` (on Jan 3, 2018), they still have TIMESTAMP data type in DB instead
|
||||
of DATETIME. The problem was that that commit did not introduce migration from TIMESTAMP to DATETIME.
|
||||
Mailtrain v2 does this migration, however in some corner cases, this may shift the date by a day back or forth.
|
||||
|
|
2
client/.gitignore
vendored
2
client/.gitignore
vendored
|
@ -1 +1 @@
|
|||
/dist
|
||||
/dist
|
||||
|
|
1647
client/package-lock.json
generated
1647
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -26,14 +26,15 @@
|
|||
"bootstrap": "^4.2.1",
|
||||
"datatables.net": "^1.10.19",
|
||||
"datatables.net-bs4": "^1.10.19",
|
||||
"ellipsize": "^0.1.0",
|
||||
"grapesjs": "^0.14.49",
|
||||
"grapesjs-mjml": "0.0.27",
|
||||
"grapesjs-mjml": "0.0.31",
|
||||
"grapesjs-preset-newsletter": "^0.2.20",
|
||||
"i18next": "^13.1.0",
|
||||
"i18next-browser-languagedetector": "^2.2.4",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"juice": "^5.1.0",
|
||||
"mjml4-in-browser": "^1.0.2",
|
||||
"mjml4-in-browser": "^1.1.0",
|
||||
"moment": "^2.23.0",
|
||||
"moment-timezone": "^0.5.23",
|
||||
"popper.js": "^1.14.6",
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
.api {
|
||||
:global .card h4 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 45px;
|
||||
}
|
||||
.api {
|
||||
:global .card h4 {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 45px;
|
||||
}
|
||||
}
|
|
@ -164,43 +164,45 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
// The source cannot be changed once campaign is created. Thus we don't have to initialize fields for all other sources
|
||||
if (data.source === CampaignSource.TEMPLATE) {
|
||||
data.data_sourceTemplate = data.data.sourceTemplate;
|
||||
}
|
||||
|
||||
if (data.source === CampaignSource.URL) {
|
||||
data.data_sourceUrl = data.data.sourceUrl;
|
||||
}
|
||||
|
||||
if (data.type === CampaignType.RSS) {
|
||||
data.data_feedUrl = data.data.feedUrl;
|
||||
}
|
||||
|
||||
for (const overridable of campaignOverridables) {
|
||||
data[overridable + '_overriden'] = data[overridable + '_override'] !== null;
|
||||
}
|
||||
|
||||
const lsts = [];
|
||||
for (const lst of data.lists) {
|
||||
const lstUid = this.getNextListEntryId();
|
||||
|
||||
const prefix = 'lists_' + lstUid + '_';
|
||||
|
||||
data[prefix + 'list'] = lst.list;
|
||||
data[prefix + 'segment'] = lst.segment;
|
||||
data[prefix + 'useSegmentation'] = !!lst.segment;
|
||||
|
||||
lsts.push(lstUid);
|
||||
}
|
||||
data.lists = lsts;
|
||||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.fetchSendConfiguration(data.send_configuration);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
// The source cannot be changed once campaign is created. Thus we don't have to initialize fields for all other sources
|
||||
if (data.source === CampaignSource.TEMPLATE) {
|
||||
data.data_sourceTemplate = data.data.sourceTemplate;
|
||||
}
|
||||
|
||||
if (data.source === CampaignSource.URL) {
|
||||
data.data_sourceUrl = data.data.sourceUrl;
|
||||
}
|
||||
|
||||
if (data.type === CampaignType.RSS) {
|
||||
data.data_feedUrl = data.data.feedUrl;
|
||||
}
|
||||
|
||||
for (const overridable of campaignOverridables) {
|
||||
data[overridable + '_overriden'] = data[overridable + '_override'] !== null;
|
||||
}
|
||||
|
||||
const lsts = [];
|
||||
for (const lst of data.lists) {
|
||||
const lstUid = this.getNextListEntryId();
|
||||
|
||||
const prefix = 'lists_' + lstUid + '_';
|
||||
|
||||
data[prefix + 'list'] = lst.list;
|
||||
data[prefix + 'segment'] = lst.segment;
|
||||
data[prefix + 'useSegmentation'] = !!lst.segment;
|
||||
|
||||
lsts.push(lstUid);
|
||||
}
|
||||
data.lists = lsts;
|
||||
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.fetchSendConfiguration(data.send_configuration);
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
if (this.props.entity.status === CampaignStatus.SENDING) {
|
||||
this.disableForm();
|
||||
|
@ -337,7 +339,13 @@ export default class CUD extends Component {
|
|||
validateNamespace(t, state);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
static AfterSubmitAction = {
|
||||
STAY: 0,
|
||||
LEAVE: 1,
|
||||
STATUS: 2
|
||||
}
|
||||
|
||||
async submitHandler(afterSubmitAction) {
|
||||
const isEdit = !!this.props.entity;
|
||||
const t = this.props.t;
|
||||
|
||||
|
@ -353,7 +361,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
data.source = Number.parseInt(data.source);
|
||||
|
||||
data.data = {};
|
||||
|
@ -411,14 +419,31 @@ export default class CUD extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitResponse) {
|
||||
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
this.navigateToWithFlashMessage('/campaigns', 'success', t('campaignSaved'));
|
||||
} else if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/content`, 'success', t('campaignSaved'));
|
||||
if (afterSubmitAction === CUD.AfterSubmitAction.STATUS) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.entity.id}/status`, 'success', t('Campaign updated'));
|
||||
} else if (afterSubmitAction === CUD.AfterSubmitAction.LEAVE) {
|
||||
this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/campaigns-settings/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Campaign updated'));
|
||||
}
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResponse}/status`, 'success', t('campaignSaved'));
|
||||
const sourceTypeKey = Number.parseInt(this.getFormValue('source'));
|
||||
|
||||
if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/content`, 'success', t('Campaign created'));
|
||||
} else {
|
||||
if (afterSubmitAction === CUD.AfterSubmitAction.STATUS) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/status`, 'success', t('Campaign created'));
|
||||
} else if (afterSubmitAction === CUD.AfterSubmitAction.LEAVE) {
|
||||
this.navigateToWithFlashMessage(`/campaigns`, 'success', t('Campaign created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${submitResult}/edit`, 'success', t('Campaign created'));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
@ -583,13 +608,21 @@ export default class CUD extends Component {
|
|||
sendSettings = [];
|
||||
|
||||
const addOverridable = (id, label) => {
|
||||
sendSettings.push(<CheckBox key={id + '_overriden'} id={id + '_overriden'} label={label} text={t('override')}/>);
|
||||
|
||||
if (this.getFormValue(id + '_overriden')) {
|
||||
sendSettings.push(<InputField key={id + '_override'} id={id + '_override'}/>);
|
||||
} else {
|
||||
if(this.state.sendConfiguration[id + '_overridable']){
|
||||
if (this.getFormValue(id + '_overriden')) {
|
||||
sendSettings.push(<InputField label={t(label)} key={id + '_override'} id={id + '_override'}/>);
|
||||
} else {
|
||||
sendSettings.push(
|
||||
<StaticField key={id + '_original'} label={t(label)} id={id + '_original'} className={styles.formDisabled}>
|
||||
{this.state.sendConfiguration[id]}
|
||||
</StaticField>
|
||||
);
|
||||
}
|
||||
sendSettings.push(<CheckBox key={id + '_overriden'} id={id + '_overriden'} text={t('override')} className={campaignsStyles.overrideCheckbox}/>);
|
||||
}
|
||||
else{
|
||||
sendSettings.push(
|
||||
<StaticField key={id + '_original'} id={id + '_original'} className={styles.formDisabled}>
|
||||
<StaticField key={id + '_original'} label={t(label)} id={id + '_original'} className={styles.formDisabled}>
|
||||
{this.state.sendConfiguration[id]}
|
||||
</StaticField>
|
||||
);
|
||||
|
@ -666,17 +699,6 @@ export default class CUD extends Component {
|
|||
templateEdit = <InputField id="data_sourceUrl" label={t('renderUrl')} help={t('ifAMessageIsSentThenThisUrlWillBePosTed')}/>
|
||||
}
|
||||
|
||||
let saveButtonLabel;
|
||||
if (isEdit) {
|
||||
saveButtonLabel = t('save');
|
||||
} else if (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) {
|
||||
saveButtonLabel = t('saveAndEditContent');
|
||||
} else {
|
||||
saveButtonLabel = t('saveCampaignAndGoToStatus');
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canDelete &&
|
||||
|
@ -719,25 +741,45 @@ export default class CUD extends Component {
|
|||
|
||||
<hr/>
|
||||
|
||||
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
|
||||
<Fieldset label={t('sendSettings')}>
|
||||
|
||||
{sendSettings}
|
||||
<TableSelect id="send_configuration" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
|
||||
|
||||
<InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/>
|
||||
{sendSettings}
|
||||
|
||||
<InputField id="unsubscribe_url" label={t('customUnsubscribeUrl')}/>
|
||||
</Fieldset>
|
||||
|
||||
<hr/>
|
||||
|
||||
<CheckBox id="open_tracking_disabled" text={t('disableOpenedTracking')}/>
|
||||
<CheckBox id="click_tracking_disabled" text={t('disableClickedTracking')}/>
|
||||
<Fieldset label={t('Tracking')}>
|
||||
<CheckBox id="open_tracking_disabled" text={t('disableOpenedTracking')}/>
|
||||
<CheckBox id="click_tracking_disabled" text={t('disableClickedTracking')}/>
|
||||
</Fieldset>
|
||||
|
||||
{sourceEdit &&
|
||||
<>
|
||||
<hr/>
|
||||
<Fieldset label={t('template')}>
|
||||
{sourceEdit}
|
||||
</Fieldset>
|
||||
</>
|
||||
}
|
||||
|
||||
{sourceEdit && <hr/> }
|
||||
|
||||
{sourceEdit}
|
||||
|
||||
{templateEdit}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={saveButtonLabel}/>
|
||||
{!isEdit && (sourceTypeKey === CampaignSource.CUSTOM || sourceTypeKey === CampaignSource.CUSTOM_FROM_TEMPLATE || sourceTypeKey === CampaignSource.CUSTOM_FROM_CAMPAIGN) ?
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and edit content')}/>
|
||||
:
|
||||
<>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(CUD.AfterSubmitAction.LEAVE)}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and go to status')} onClickAsync={async () => this.submitHandler(CUD.AfterSubmitAction.STATUS)}/>
|
||||
</>
|
||||
}
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.entity.id}/delete`}/> }
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -67,10 +67,12 @@ export default class CustomContent extends Component {
|
|||
}
|
||||
|
||||
static propTypes = {
|
||||
entity: PropTypes.object
|
||||
entity: PropTypes.object,
|
||||
setPanelInFullScreen: PropTypes.func
|
||||
}
|
||||
|
||||
loadFromEntityMutator(data) {
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.data_sourceCustom_type = data.data.sourceCustom.type;
|
||||
data.data_sourceCustom_data = data.data.sourceCustom.data;
|
||||
data.data_sourceCustom_html = data.data.sourceCustom.html;
|
||||
|
@ -79,8 +81,9 @@ export default class CustomContent extends Component {
|
|||
this.templateTypes[data.data.sourceCustom.type].afterLoad(data);
|
||||
}
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => this.loadFromEntityMutator(data));
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
}
|
||||
|
||||
localValidateFormValues(state) {
|
||||
|
@ -94,14 +97,16 @@ export default class CustomContent extends Component {
|
|||
}
|
||||
|
||||
async save() {
|
||||
await this.doSave(true);
|
||||
await this.submitHandler(CustomContent.AfterSubmitAction.STAY);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
await this.doSave(false);
|
||||
static AfterSubmitAction = {
|
||||
STAY: 0,
|
||||
LEAVE: 1,
|
||||
STATUS: 2
|
||||
}
|
||||
|
||||
async doSave(stayOnPage) {
|
||||
async submitHandler(afterSubmitAction) {
|
||||
const t = this.props.t;
|
||||
|
||||
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
|
||||
|
@ -113,7 +118,7 @@ export default class CustomContent extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
Object.assign(data, exportedData);
|
||||
this.templateTypes[data.data_sourceCustom_type].beforeSave(data);
|
||||
|
||||
|
@ -131,15 +136,15 @@ export default class CustomContent extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitResponse) {
|
||||
if (stayOnPage) {
|
||||
await this.getFormValuesFromURL(`rest/campaigns-content/${this.props.entity.id}`, data => this.loadFromEntityMutator(data));
|
||||
this.enableForm();
|
||||
this.clearFormStatusMessage();
|
||||
this.setFlashMessage('success', t('campaignSaved'));
|
||||
|
||||
if (submitResult) {
|
||||
if (afterSubmitAction === CustomContent.AfterSubmitAction.STATUS) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.entity.id}/status`, 'success', t('Campaign updated'));
|
||||
} else if (afterSubmitAction === CustomContent.AfterSubmitAction.LEAVE) {
|
||||
this.navigateToWithFlashMessage('/campaigns', 'success', t('Campaign updated'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage('/campaigns', 'success', t('campaignSaved'));
|
||||
await this.getFormValuesFromURL(`rest/campaigns-content/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Campaign updated'));
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
@ -177,6 +182,7 @@ export default class CustomContent extends Component {
|
|||
}
|
||||
|
||||
async setElementInFullscreen(elementInFullscreen) {
|
||||
this.props.setPanelInFullScreen(elementInFullscreen);
|
||||
this.setState({
|
||||
elementInFullscreen
|
||||
});
|
||||
|
@ -228,8 +234,10 @@ export default class CustomContent extends Component {
|
|||
{customTemplateTypeKey && getEditForm(this, customTemplateTypeKey, 'data_sourceCustom_')}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button className="btn-danger" icon="send" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(CustomContent.AfterSubmitAction.LEAVE)}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and go to status')} onClickAsync={async () => this.submitHandler(CustomContent.AfterSubmitAction.STATUS)}/>
|
||||
<Button className="btn-success" icon="at" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/>
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -225,6 +225,18 @@ class SendControls extends Component {
|
|||
await this.refreshEntity();
|
||||
}
|
||||
|
||||
async confirmStart() {
|
||||
const t = this.props.t;
|
||||
this.actionDialog(
|
||||
t('confirmLaunch'),
|
||||
t('doYouWantToLaunchTheCampaign?All'),
|
||||
async () => {
|
||||
await this.startAsync();
|
||||
await this.refreshEntity();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async resetAsync() {
|
||||
const t = this.props.t;
|
||||
this.actionDialog(
|
||||
|
@ -306,7 +318,7 @@ class SendControls extends Component {
|
|||
{this.getFormValue('sendLater') ?
|
||||
<Button className="btn-primary" icon="send" label={(entity.scheduled ? t('rescheduleSend') : t('scheduleSend')) + subscrInfo} onClickAsync={::this.scheduleAsync}/>
|
||||
:
|
||||
<Button className="btn-primary" icon="send" label={t('send') + subscrInfo} onClickAsync={::this.startAsync}/>
|
||||
<Button className="btn-primary" icon="send" label={t('send') + subscrInfo} onClickAsync={::this.confirmStart}/>
|
||||
}
|
||||
{entity.status === CampaignStatus.PAUSED && <LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>}
|
||||
</ButtonRow>
|
||||
|
@ -335,7 +347,7 @@ class SendControls extends Component {
|
|||
{t('allMessagesSent!HitContinueIfYouYouWant')}
|
||||
</AlignedRow>
|
||||
<ButtonRow>
|
||||
<Button className="btn-primary" icon="play" label={t('continue') + subscrInfo} onClickAsync={::this.startAsync}/>
|
||||
<Button className="btn-primary" icon="play" label={t('continue') + subscrInfo} onClickAsync={::this.confirmStart}/>
|
||||
<Button className="btn-primary" icon="refresh" label={t('reset')} onClickAsync={::this.resetAsync}/>
|
||||
<LinkButton className="btn-secondary" icon="signal" label={t('viewStatistics')} to={`/campaigns/${entity.id}/statistics`}/>
|
||||
</ButtonRow>
|
||||
|
@ -443,11 +455,17 @@ export default class Status extends Component {
|
|||
let sendSettings;
|
||||
if (this.state.sendConfiguration) {
|
||||
sendSettings = [];
|
||||
|
||||
|
||||
const addOverridable = (id, label) => {
|
||||
sendSettings.push(<AlignedRow key={id} label={label}>{entity[id + '_override'] === null ? this.state.sendConfiguration[id] : entity[id + '_override']}</AlignedRow>);
|
||||
if(this.state.sendConfiguration[id + '_overridable'] == 1 && entity[id + '_override'] != null){
|
||||
sendSettings.push(<AlignedRow key={id} label={label}>{entity[id + '_override']}</AlignedRow>);
|
||||
}
|
||||
else{
|
||||
sendSettings.push(<AlignedRow key={id} label={label}>{this.state.sendConfiguration[id]}</AlignedRow>);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
addOverridable('from_name', t('fromName'));
|
||||
addOverridable('from_email', t('fromEmailAddress'));
|
||||
addOverridable('reply_to', t('replytoEmailAddress'));
|
||||
|
|
|
@ -115,8 +115,8 @@ export class TestSendModalDialog extends Component {
|
|||
|
||||
return (
|
||||
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
|
||||
{ label: t('send'), className: 'btn-danger', onClickAsync: ::this.performAction },
|
||||
{ label: t('cancel'), className: 'btn-primary', onClickAsync: ::this.hideModal }
|
||||
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
|
||||
{ label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
|
||||
]}>
|
||||
<Form stateOwner={this} format="wide">
|
||||
<TableSelect id="testUser" format="wide" label={t('subscription')} withHeader dropdown dataUrl={`rest/campaigns-test-users-table/${this.props.entity.id}`} columns={testUsersColumns} selectionLabelIndex={1} />
|
||||
|
|
|
@ -1,34 +1,34 @@
|
|||
'use strict';
|
||||
|
||||
import {
|
||||
CampaignStatus,
|
||||
CampaignType
|
||||
} from "../../../shared/campaigns";
|
||||
|
||||
export function getCampaignLabels(t) {
|
||||
|
||||
const campaignTypeLabels = {
|
||||
[CampaignType.REGULAR]: t('regular'),
|
||||
[CampaignType.TRIGGERED]: t('triggered'),
|
||||
[CampaignType.RSS]: t('rss')
|
||||
};
|
||||
|
||||
const campaignStatusLabels = {
|
||||
[CampaignStatus.IDLE]: t('idle'),
|
||||
[CampaignStatus.SCHEDULED]: t('scheduled'),
|
||||
[CampaignStatus.PAUSED]: t('paused'),
|
||||
[CampaignStatus.FINISHED]: t('finished'),
|
||||
[CampaignStatus.PAUSED]: t('paused'),
|
||||
[CampaignStatus.INACTIVE]: t('inactive'),
|
||||
[CampaignStatus.ACTIVE]: t('active'),
|
||||
[CampaignStatus.SENDING]: t('sending')
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
campaignStatusLabels,
|
||||
campaignTypeLabels
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
'use strict';
|
||||
|
||||
import {
|
||||
CampaignStatus,
|
||||
CampaignType
|
||||
} from "../../../shared/campaigns";
|
||||
|
||||
export function getCampaignLabels(t) {
|
||||
|
||||
const campaignTypeLabels = {
|
||||
[CampaignType.REGULAR]: t('regular'),
|
||||
[CampaignType.TRIGGERED]: t('triggered'),
|
||||
[CampaignType.RSS]: t('rss')
|
||||
};
|
||||
|
||||
const campaignStatusLabels = {
|
||||
[CampaignStatus.IDLE]: t('idle'),
|
||||
[CampaignStatus.SCHEDULED]: t('scheduled'),
|
||||
[CampaignStatus.PAUSED]: t('paused'),
|
||||
[CampaignStatus.FINISHED]: t('finished'),
|
||||
[CampaignStatus.PAUSED]: t('paused'),
|
||||
[CampaignStatus.INACTIVE]: t('inactive'),
|
||||
[CampaignStatus.ACTIVE]: t('active'),
|
||||
[CampaignStatus.SENDING]: t('sending')
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
campaignStatusLabels,
|
||||
campaignTypeLabels
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -33,7 +33,8 @@ import StatisticsOpened
|
|||
from "./StatisticsOpened";
|
||||
import StatisticsLinkClicks
|
||||
from "./StatisticsLinkClicks";
|
||||
|
||||
import TemplatesCUD from "../templates/root";
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers"
|
||||
|
||||
function getMenus(t) {
|
||||
const aggLabels = {
|
||||
|
@ -48,7 +49,7 @@ function getMenus(t) {
|
|||
panelComponent: CampaignsList,
|
||||
children: {
|
||||
':campaignId([0-9]+)': {
|
||||
title: resolved => t('campaignName', {name: resolved.campaign.name}),
|
||||
title: resolved => t('campaignName', {name: ellipsizeBreadcrumbLabel(resolved.campaign.name)}),
|
||||
resolve: {
|
||||
campaign: params => `rest/campaigns-settings/${params.campaignId}`
|
||||
},
|
||||
|
@ -120,7 +121,7 @@ function getMenus(t) {
|
|||
campaignContent: params => `rest/campaigns-content/${params.campaignId}`
|
||||
},
|
||||
visible: resolved => resolved.campaign.permissions.includes('edit') && (resolved.campaign.source === CampaignSource.CUSTOM || resolved.campaign.source === CampaignSource.CUSTOM_FROM_TEMPLATE || resolved.campaign.source === CampaignSource.CUSTOM_FROM_CAMPAIGN),
|
||||
panelRender: props => <Content entity={props.resolved.campaignContent} />
|
||||
panelRender: props => <Content entity={props.resolved.campaignContent} setPanelInFullScreen={props.setPanelInFullScreen} />
|
||||
},
|
||||
files: {
|
||||
title: t('files'),
|
||||
|
@ -141,7 +142,7 @@ function getMenus(t) {
|
|||
panelRender: props => <TriggersList campaign={props.resolved.campaign} />,
|
||||
children: {
|
||||
':triggerId([0-9]+)': {
|
||||
title: resolved => t('triggerName', {name: resolved.trigger.name}),
|
||||
title: resolved => t('triggerName', {name: ellipsizeBreadcrumbLabel(resolved.trigger.name)}),
|
||||
resolve: {
|
||||
trigger: params => `rest/triggers/${params.campaignId}/${params.triggerId}`,
|
||||
},
|
||||
|
|
|
@ -1,92 +1,94 @@
|
|||
.entry {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
margin-bottom: 15px;
|
||||
min-height: 91px;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0px none;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.entryButtons {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 19px;
|
||||
|
||||
button {
|
||||
padding: 2px 3px;
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
button:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&.entryWithButtons > .entryContent {
|
||||
margin-right: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.newEntry {
|
||||
text-align: right;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sendButtonRow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.statsMetrics {
|
||||
width: 10ex;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.statsProgressBar {
|
||||
margin-right: 30px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.statsProgressBarZoomIn {
|
||||
float: right;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zoomIn {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.navPills {
|
||||
margin-top: -3px;
|
||||
margin-bottom: 5px;
|
||||
float: right;
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
|
||||
& > a {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.chart {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
|
||||
.entry {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
margin-bottom: 15px;
|
||||
min-height: 91px;
|
||||
position: relative;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0px none;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.entryButtons {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 19px;
|
||||
|
||||
button {
|
||||
padding: 2px 3px;
|
||||
font-size: 11px;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
button:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
&.entryWithButtons > .entryContent {
|
||||
margin-right: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
.newEntry {
|
||||
text-align: right;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sendButtonRow {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.statsMetrics {
|
||||
width: 10ex;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.statsProgressBar {
|
||||
margin-right: 30px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.statsProgressBarZoomIn {
|
||||
float: right;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.zoomIn {
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.navPills {
|
||||
margin-top: -3px;
|
||||
margin-bottom: 5px;
|
||||
float: right;
|
||||
|
||||
& > li {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
|
||||
& > a {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.charts {
|
||||
margin-bottom: 30px;
|
||||
|
||||
.chart {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.overrideCheckbox{
|
||||
margin-top: -8px !important;
|
||||
}
|
||||
|
|
|
@ -86,23 +86,25 @@ export default class CUD extends Component {
|
|||
entity: PropTypes.object
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.daysAfter = (Math.round(data.seconds / (3600 * 24))).toString();
|
||||
|
||||
if (data.entity === Entity.SUBSCRIPTION) {
|
||||
data.subscriptionEvent = data.event;
|
||||
} else {
|
||||
data.subscriptionEvent = Event[Entity.SUBSCRIPTION].CREATED;
|
||||
}
|
||||
|
||||
if (data.entity === Entity.CAMPAIGN) {
|
||||
data.campaignEvent = data.event;
|
||||
} else {
|
||||
data.campaignEvent = Event[Entity.CAMPAIGN].DELIVERED;
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.daysAfter = (Math.round(data.seconds / (3600 * 24))).toString();
|
||||
|
||||
if (data.entity === Entity.SUBSCRIPTION) {
|
||||
data.subscriptionEvent = data.event;
|
||||
} else {
|
||||
data.subscriptionEvent = Event[Entity.SUBSCRIPTION].CREATED;
|
||||
}
|
||||
|
||||
if (data.entity === Entity.CAMPAIGN) {
|
||||
data.campaignEvent = data.event;
|
||||
} else {
|
||||
data.campaignEvent = Event[Entity.CAMPAIGN].DELIVERED;
|
||||
}
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
|
@ -145,7 +147,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -161,7 +163,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
data.seconds = Number.parseInt(data.daysAfter) * 3600 * 24;
|
||||
|
||||
if (data.entity === Entity.SUBSCRIPTION) {
|
||||
|
@ -171,8 +173,22 @@ export default class CUD extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('triggerSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('Trigger updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/triggers/${this.props.campaign.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Trigger updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers`, 'success', t('Trigger created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/campaigns/${this.props.campaign.id}/triggers/${submitResult}/edit`, 'success', t('Trigger created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -235,7 +251,8 @@ export default class CUD extends Component {
|
|||
<CheckBox id="enabled" text={t('enabled')}/>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/campaigns/${this.props.campaign.id}/triggers/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
'use strict';
|
||||
|
||||
import {Entity, Event} from '../../../../shared/triggers';
|
||||
|
||||
export function getTriggerTypes(t) {
|
||||
|
||||
const entityLabels = {
|
||||
[Entity.SUBSCRIPTION]: t('subscription'),
|
||||
[Entity.CAMPAIGN]: t('campaign')
|
||||
};
|
||||
|
||||
const SubscriptionEvent = Event[Entity.SUBSCRIPTION];
|
||||
const CampaignEvent = Event[Entity.CAMPAIGN];
|
||||
|
||||
const eventLabels = {
|
||||
[Entity.SUBSCRIPTION]: {
|
||||
[SubscriptionEvent.CREATED]: t('created'),
|
||||
[SubscriptionEvent.LATEST_OPEN]: t('latestOpen'),
|
||||
[SubscriptionEvent.LATEST_CLICK]: t('latestClick')
|
||||
},
|
||||
[Entity.CAMPAIGN]: {
|
||||
[CampaignEvent.DELIVERED]: t('delivered'),
|
||||
[CampaignEvent.OPENED]: t('opened'),
|
||||
[CampaignEvent.CLICKED]: t('clicked'),
|
||||
[CampaignEvent.NOT_OPENED]: t('notOpened'),
|
||||
[CampaignEvent.NOT_CLICKED]: t('notClicked')
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
entityLabels,
|
||||
eventLabels
|
||||
};
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
import {Entity, Event} from '../../../../shared/triggers';
|
||||
|
||||
export function getTriggerTypes(t) {
|
||||
|
||||
const entityLabels = {
|
||||
[Entity.SUBSCRIPTION]: t('subscription'),
|
||||
[Entity.CAMPAIGN]: t('campaign')
|
||||
};
|
||||
|
||||
const SubscriptionEvent = Event[Entity.SUBSCRIPTION];
|
||||
const CampaignEvent = Event[Entity.CAMPAIGN];
|
||||
|
||||
const eventLabels = {
|
||||
[Entity.SUBSCRIPTION]: {
|
||||
[SubscriptionEvent.CREATED]: t('created'),
|
||||
[SubscriptionEvent.LATEST_OPEN]: t('latestOpen'),
|
||||
[SubscriptionEvent.LATEST_CLICK]: t('latestClick')
|
||||
},
|
||||
[Entity.CAMPAIGN]: {
|
||||
[CampaignEvent.DELIVERED]: t('delivered'),
|
||||
[CampaignEvent.OPENED]: t('opened'),
|
||||
[CampaignEvent.CLICKED]: t('clicked'),
|
||||
[CampaignEvent.NOT_OPENED]: t('notOpened'),
|
||||
[CampaignEvent.NOT_CLICKED]: t('notClicked')
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
entityLabels,
|
||||
eventLabels
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,32 @@
|
|||
'use strict';
|
||||
|
||||
import csrfToken from 'csrfToken';
|
||||
import axios from 'axios';
|
||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||
|
||||
const axiosInst = axios.create({
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const axiosWrapper = {
|
||||
get: (...args) => axiosInst.get(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
|
||||
put: (...args) => axiosInst.put(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
|
||||
post: (...args) => axiosInst.post(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
|
||||
delete: (...args) => axiosInst.delete(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error })
|
||||
};
|
||||
|
||||
const HTTPMethod = {
|
||||
GET: axiosWrapper.get,
|
||||
PUT: axiosWrapper.put,
|
||||
POST: axiosWrapper.post,
|
||||
DELETE: axiosWrapper.delete
|
||||
};
|
||||
|
||||
axiosWrapper.method = (method, ...args) => method(...args);
|
||||
|
||||
export default axiosWrapper;
|
||||
export {
|
||||
HTTPMethod
|
||||
'use strict';
|
||||
|
||||
import csrfToken from 'csrfToken';
|
||||
import axios from 'axios';
|
||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||
|
||||
const axiosInst = axios.create({
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken
|
||||
}
|
||||
});
|
||||
|
||||
const axiosWrapper = {
|
||||
get: (...args) => axiosInst.get(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
|
||||
put: (...args) => axiosInst.put(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
|
||||
post: (...args) => axiosInst.post(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error }),
|
||||
delete: (...args) => axiosInst.delete(...args).catch(error => { throw (error.response && interoperableErrors.deserialize(error.response.data)) || error })
|
||||
};
|
||||
|
||||
const HTTPMethod = {
|
||||
GET: axiosWrapper.get,
|
||||
PUT: axiosWrapper.put,
|
||||
POST: axiosWrapper.post,
|
||||
DELETE: axiosWrapper.delete
|
||||
};
|
||||
|
||||
axiosWrapper.method = (method, ...args) => method(...args);
|
||||
|
||||
export default axiosWrapper;
|
||||
export {
|
||||
HTTPMethod
|
||||
}
|
661
client/src/lib/bootstrap-components.js
vendored
661
client/src/lib/bootstrap-components.js
vendored
|
@ -1,330 +1,331 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import {withTranslation} from './i18n';
|
||||
import PropTypes
|
||||
from 'prop-types';
|
||||
import {
|
||||
withAsyncErrorHandler,
|
||||
withErrorHandling
|
||||
} from './error-handling';
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
withErrorHandling
|
||||
])
|
||||
export class DismissibleAlert extends Component {
|
||||
static propTypes = {
|
||||
severity: PropTypes.string.isRequired,
|
||||
onCloseAsync: PropTypes.func
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
onClose() {
|
||||
if (this.props.onCloseAsync) {
|
||||
this.props.onCloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
return (
|
||||
<div className={`alert alert-${this.props.severity} alert-dismissible`} role="alert">
|
||||
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">×</span></button>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class Icon extends Component {
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
family: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
family: 'fas'
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
if (props.family === 'fas' || props.family === 'far') {
|
||||
return <i className={`${props.family} fa-${props.icon} ${props.className || ''}`} title={props.title}></i>;
|
||||
} else {
|
||||
console.error(`Icon font family ${props.family} not supported. (icon: ${props.icon}, title: ${props.title})`)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@withComponentMixins([
|
||||
withErrorHandling
|
||||
])
|
||||
export class Button extends Component {
|
||||
static propTypes = {
|
||||
onClickAsync: PropTypes.func,
|
||||
label: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
iconTitle: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async onClick(evt) {
|
||||
if (this.props.onClickAsync) {
|
||||
evt.preventDefault();
|
||||
await this.props.onClickAsync(evt);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let className = 'btn';
|
||||
if (props.className) {
|
||||
className = className + ' ' + props.className;
|
||||
}
|
||||
|
||||
let type = props.type || 'button';
|
||||
|
||||
let icon;
|
||||
if (props.icon) {
|
||||
icon = <Icon icon={props.icon} title={props.iconTitle}/>
|
||||
}
|
||||
|
||||
let iconSpacer;
|
||||
if (props.icon && props.label) {
|
||||
iconSpacer = ' ';
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} className={className} onClick={::this.onClick} title={this.props.title}>{icon}{iconSpacer}{props.label}</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonDropdown extends Component {
|
||||
static propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
className: PropTypes.string,
|
||||
buttonClassName: PropTypes.string,
|
||||
menuClassName: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
const className = 'dropdown' + (props.className ? ' ' + props.className : '');
|
||||
const buttonClassName = 'btn dropdown-toggle' + (props.buttonClassName ? ' ' + props.buttonClassName : '');
|
||||
const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
|
||||
|
||||
return (
|
||||
<div className="dropdown" className={className}>
|
||||
<button type="button" className={buttonClassName} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{props.label}
|
||||
</button>
|
||||
<ul className={menuClassName}>
|
||||
{props.children}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@withComponentMixins([
|
||||
withErrorHandling
|
||||
])
|
||||
export class ActionLink extends Component {
|
||||
static propTypes = {
|
||||
onClickAsync: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
href: PropTypes.string
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async onClick(evt) {
|
||||
if (this.props.onClickAsync) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
await this.props.onClickAsync(evt);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
return (
|
||||
<a href={props.href || ''} className={props.className} onClick={::this.onClick}>{props.children}</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class DropdownActionLink extends Component {
|
||||
static propTypes = {
|
||||
onClickAsync: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let clsName = "dropdown-item ";
|
||||
if (props.disabled) {
|
||||
clsName += "disabled ";
|
||||
}
|
||||
|
||||
clsName += props.className;
|
||||
|
||||
return (
|
||||
<ActionLink className={clsName} onClickAsync={props.onClickAsync}>{props.children}</ActionLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class DropdownDivider extends Component {
|
||||
static propTypes = {
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let className = 'dropdown-divider';
|
||||
if (props.className) {
|
||||
className = className + ' ' + props.className;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
withErrorHandling
|
||||
])
|
||||
export class ModalDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const t = props.t;
|
||||
|
||||
this.state = {
|
||||
buttons: this.props.buttons || [ { label: t('close'), className: 'btn-secondary', onClickAsync: null } ]
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
onCloseAsync: PropTypes.func,
|
||||
onButtonClickAsync: PropTypes.func,
|
||||
buttons: PropTypes.array,
|
||||
hidden: PropTypes.bool,
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
/*
|
||||
this.props.hidden - this is the desired state of the modal
|
||||
this.hidden - this is the actual state of the modal - this is because there is no public API on Bootstrap modal to know whether the modal is shown or not
|
||||
*/
|
||||
|
||||
componentDidMount() {
|
||||
const jqModal = jQuery(this.domModal);
|
||||
|
||||
jqModal.on('shown.bs.modal', () => jqModal.focus());
|
||||
jqModal.on('hide.bs.modal', ::this.onHide);
|
||||
|
||||
this.hidden = this.props.hidden;
|
||||
jqModal.modal({
|
||||
show: !this.props.hidden
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.hidden != this.hidden) {
|
||||
const jqModal = jQuery(this.domModal);
|
||||
this.hidden = this.props.hidden;
|
||||
jqModal.modal(this.props.hidden ? 'hide' : 'show');
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// We discard the modal in a hard way (without hiding it). Thus we have to take care of the backgrop too.
|
||||
jQuery('.modal-backdrop').remove();
|
||||
}
|
||||
|
||||
onHide(evt) {
|
||||
// Hide event is emited is both when hidden through user action or through API. We have to let the API
|
||||
// calls through, otherwise the modal would never hide. The user actions, which change the desired state,
|
||||
// are capture, converted to onClose callback and prevented. It's up to the parent to decide whether to
|
||||
// hide the modal or not.
|
||||
if (!this.props.hidden) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.onClose();
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async onClose() {
|
||||
if (this.props.onCloseAsync) {
|
||||
await this.props.onCloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async onButtonClick(idx) {
|
||||
const buttonSpec = this.state.buttons[idx];
|
||||
if (buttonSpec.onClickAsync) {
|
||||
await buttonSpec.onClickAsync(idx);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
const t = props.t;
|
||||
|
||||
const buttons = [];
|
||||
for (let idx = 0; idx < this.state.buttons.length; idx++) {
|
||||
const buttonSpec = this.state.buttons[idx];
|
||||
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={() => this.onButtonClick(idx)} />
|
||||
buttons.push(button);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(domElem) => { this.domModal = domElem; }}
|
||||
className={'modal fade' + (props.className ? ' ' + props.className : '')}
|
||||
tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
|
||||
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h4 className="modal-title">{this.props.title}</h4>
|
||||
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div className="modal-body">{this.props.children}</div>
|
||||
<div className="modal-footer">
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import {withTranslation} from './i18n';
|
||||
import PropTypes
|
||||
from 'prop-types';
|
||||
import {
|
||||
withAsyncErrorHandler,
|
||||
withErrorHandling
|
||||
} from './error-handling';
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
withErrorHandling
|
||||
])
|
||||
export class DismissibleAlert extends Component {
|
||||
static propTypes = {
|
||||
severity: PropTypes.string.isRequired,
|
||||
onCloseAsync: PropTypes.func
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
onClose() {
|
||||
if (this.props.onCloseAsync) {
|
||||
this.props.onCloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
return (
|
||||
<div className={`alert alert-${this.props.severity} alert-dismissible`} role="alert">
|
||||
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">×</span></button>
|
||||
{this.props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class Icon extends Component {
|
||||
static propTypes = {
|
||||
icon: PropTypes.string.isRequired,
|
||||
family: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
family: 'fas'
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
if (props.family === 'fas' || props.family === 'far') {
|
||||
return <i className={`${props.family} fa-${props.icon} ${props.className || ''}`} title={props.title}></i>;
|
||||
} else {
|
||||
console.error(`Icon font family ${props.family} not supported. (icon: ${props.icon}, title: ${props.title})`)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@withComponentMixins([
|
||||
withErrorHandling
|
||||
])
|
||||
export class Button extends Component {
|
||||
static propTypes = {
|
||||
onClickAsync: PropTypes.func,
|
||||
label: PropTypes.string,
|
||||
icon: PropTypes.string,
|
||||
iconTitle: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
disabled: PropTypes.bool
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async onClick(evt) {
|
||||
if (this.props.onClickAsync) {
|
||||
evt.preventDefault();
|
||||
await this.props.onClickAsync(evt);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let className = 'btn';
|
||||
if (props.className) {
|
||||
className = className + ' ' + props.className;
|
||||
}
|
||||
|
||||
let type = props.type || 'button';
|
||||
|
||||
let icon;
|
||||
if (props.icon) {
|
||||
icon = <Icon icon={props.icon} title={props.iconTitle}/>
|
||||
}
|
||||
|
||||
let iconSpacer;
|
||||
if (props.icon && props.label) {
|
||||
iconSpacer = ' ';
|
||||
}
|
||||
|
||||
return (
|
||||
<button type={type} className={className} onClick={::this.onClick} title={this.props.title} disabled={this.props.disabled}>{icon}{iconSpacer}{props.label}</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonDropdown extends Component {
|
||||
static propTypes = {
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
className: PropTypes.string,
|
||||
buttonClassName: PropTypes.string,
|
||||
menuClassName: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
const className = 'dropdown' + (props.className ? ' ' + props.className : '');
|
||||
const buttonClassName = 'btn dropdown-toggle' + (props.buttonClassName ? ' ' + props.buttonClassName : '');
|
||||
const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
|
||||
|
||||
return (
|
||||
<div className="dropdown" className={className}>
|
||||
<button type="button" className={buttonClassName} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{props.label}
|
||||
</button>
|
||||
<ul className={menuClassName}>
|
||||
{props.children}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@withComponentMixins([
|
||||
withErrorHandling
|
||||
])
|
||||
export class ActionLink extends Component {
|
||||
static propTypes = {
|
||||
onClickAsync: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
href: PropTypes.string
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async onClick(evt) {
|
||||
if (this.props.onClickAsync) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
await this.props.onClickAsync(evt);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
return (
|
||||
<a href={props.href || ''} className={props.className} onClick={::this.onClick}>{props.children}</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class DropdownActionLink extends Component {
|
||||
static propTypes = {
|
||||
onClickAsync: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let clsName = "dropdown-item ";
|
||||
if (props.disabled) {
|
||||
clsName += "disabled ";
|
||||
}
|
||||
|
||||
clsName += props.className;
|
||||
|
||||
return (
|
||||
<ActionLink className={clsName} onClickAsync={props.onClickAsync}>{props.children}</ActionLink>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export class DropdownDivider extends Component {
|
||||
static propTypes = {
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
|
||||
let className = 'dropdown-divider';
|
||||
if (props.className) {
|
||||
className = className + ' ' + props.className;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
withErrorHandling
|
||||
])
|
||||
export class ModalDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const t = props.t;
|
||||
|
||||
this.state = {
|
||||
buttons: this.props.buttons || [ { label: t('close'), className: 'btn-secondary', onClickAsync: null } ]
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
title: PropTypes.string,
|
||||
onCloseAsync: PropTypes.func,
|
||||
onButtonClickAsync: PropTypes.func,
|
||||
buttons: PropTypes.array,
|
||||
hidden: PropTypes.bool,
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
/*
|
||||
this.props.hidden - this is the desired state of the modal
|
||||
this.hidden - this is the actual state of the modal - this is because there is no public API on Bootstrap modal to know whether the modal is shown or not
|
||||
*/
|
||||
|
||||
componentDidMount() {
|
||||
const jqModal = jQuery(this.domModal);
|
||||
|
||||
jqModal.on('shown.bs.modal', () => jqModal.focus());
|
||||
jqModal.on('hide.bs.modal', ::this.onHide);
|
||||
|
||||
this.hidden = this.props.hidden;
|
||||
jqModal.modal({
|
||||
show: !this.props.hidden
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.hidden != this.hidden) {
|
||||
const jqModal = jQuery(this.domModal);
|
||||
this.hidden = this.props.hidden;
|
||||
jqModal.modal(this.props.hidden ? 'hide' : 'show');
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// We discard the modal in a hard way (without hiding it). Thus we have to take care of the backgrop too.
|
||||
jQuery('.modal-backdrop').remove();
|
||||
}
|
||||
|
||||
onHide(evt) {
|
||||
// Hide event is emited is both when hidden through user action or through API. We have to let the API
|
||||
// calls through, otherwise the modal would never hide. The user actions, which change the desired state,
|
||||
// are capture, converted to onClose callback and prevented. It's up to the parent to decide whether to
|
||||
// hide the modal or not.
|
||||
if (!this.props.hidden) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.onClose();
|
||||
evt.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async onClose() {
|
||||
if (this.props.onCloseAsync) {
|
||||
await this.props.onCloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async onButtonClick(idx) {
|
||||
const buttonSpec = this.state.buttons[idx];
|
||||
if (buttonSpec.onClickAsync) {
|
||||
await buttonSpec.onClickAsync(idx);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
const t = props.t;
|
||||
|
||||
const buttons = [];
|
||||
for (let idx = 0; idx < this.state.buttons.length; idx++) {
|
||||
const buttonSpec = this.state.buttons[idx];
|
||||
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={async () => this.onButtonClick(idx)} />
|
||||
buttons.push(button);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(domElem) => { this.domModal = domElem; }}
|
||||
className={'modal fade' + (props.className ? ' ' + props.className : '')}
|
||||
tabIndex="-1" role="dialog" aria-labelledby="myModalLabel">
|
||||
|
||||
<div className="modal-dialog" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h4 className="modal-title">{this.props.title}</h4>
|
||||
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div className="modal-body">{this.props.children}</div>
|
||||
<div className="modal-footer">
|
||||
{buttons}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,128 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
|
||||
export function createComponentMixin(contexts, deps, decoratorFn) {
|
||||
return {
|
||||
contexts,
|
||||
deps,
|
||||
decoratorFn
|
||||
};
|
||||
}
|
||||
|
||||
export function withComponentMixins(mixins, delegateFuns) {
|
||||
const mixinsClosure = new Set();
|
||||
for (const mixin of mixins) {
|
||||
mixinsClosure.add(mixin);
|
||||
for (const dep of mixin.deps) {
|
||||
mixinsClosure.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
const contexts = new Map();
|
||||
for (const mixin of mixinsClosure.values()) {
|
||||
for (const ctx of mixin.contexts) {
|
||||
contexts.set(ctx.propName, ctx.context);
|
||||
}
|
||||
}
|
||||
|
||||
return TargetClass => {
|
||||
const ctors = [];
|
||||
const mixinDelegateFuns = [];
|
||||
|
||||
if (delegateFuns) {
|
||||
mixinDelegateFuns.push(...delegateFuns);
|
||||
}
|
||||
|
||||
function TargetClassWithCtors(props) {
|
||||
if (!new.target) {
|
||||
throw new TypeError();
|
||||
}
|
||||
|
||||
const self = Reflect.construct(TargetClass, [props], new.target);
|
||||
|
||||
for (const ctor of ctors) {
|
||||
ctor(self, props);
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
TargetClassWithCtors.prototype = TargetClass.prototype;
|
||||
|
||||
for (const attr in TargetClass) {
|
||||
TargetClassWithCtors[attr] = TargetClass[attr];
|
||||
}
|
||||
|
||||
|
||||
class ComponentMixinsInner extends React.Component {
|
||||
render() {
|
||||
const props = {
|
||||
...this.props,
|
||||
ref: this.props._decoratorInnerInstanceRefFn
|
||||
};
|
||||
delete props._decoratorInnerInstanceRefFn;
|
||||
|
||||
return (
|
||||
<TargetClassWithCtors {...props}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let DecoratedInner = ComponentMixinsInner;
|
||||
|
||||
for (const mixin of mixinsClosure.values()) {
|
||||
const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors);
|
||||
|
||||
if (res.cls) {
|
||||
DecoratedInner = res.cls;
|
||||
}
|
||||
|
||||
if (res.ctor) {
|
||||
ctors.push(res.ctor);
|
||||
}
|
||||
|
||||
if (res.delegateFuns) {
|
||||
mixinDelegateFuns.push(...res.delegateFuns);
|
||||
}
|
||||
}
|
||||
|
||||
class ComponentMixinsOuter extends React.Component {
|
||||
render() {
|
||||
let innerFn = parentProps => {
|
||||
const props = {
|
||||
...parentProps,
|
||||
_decoratorInnerInstanceRefFn: node => this._decoratorInnerInstance = node
|
||||
};
|
||||
|
||||
return <DecoratedInner {...props}/>
|
||||
}
|
||||
|
||||
for (const [propName, Context] of contexts.entries()) {
|
||||
const existingInnerFn = innerFn;
|
||||
innerFn = parentProps => (
|
||||
<Context.Consumer>
|
||||
{
|
||||
value => existingInnerFn({
|
||||
...parentProps,
|
||||
[propName]: value
|
||||
})
|
||||
}
|
||||
</Context.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
return innerFn(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fun of mixinDelegateFuns) {
|
||||
ComponentMixinsOuter.prototype[fun] = function (...args) {
|
||||
return this._decoratorInnerInstance[fun](...args);
|
||||
}
|
||||
}
|
||||
|
||||
return ComponentMixinsOuter;
|
||||
};
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
|
||||
export function createComponentMixin(contexts, deps, decoratorFn) {
|
||||
return {
|
||||
contexts,
|
||||
deps,
|
||||
decoratorFn
|
||||
};
|
||||
}
|
||||
|
||||
export function withComponentMixins(mixins, delegateFuns) {
|
||||
const mixinsClosure = new Set();
|
||||
for (const mixin of mixins) {
|
||||
mixinsClosure.add(mixin);
|
||||
for (const dep of mixin.deps) {
|
||||
mixinsClosure.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
const contexts = new Map();
|
||||
for (const mixin of mixinsClosure.values()) {
|
||||
for (const ctx of mixin.contexts) {
|
||||
contexts.set(ctx.propName, ctx.context);
|
||||
}
|
||||
}
|
||||
|
||||
return TargetClass => {
|
||||
const ctors = [];
|
||||
const mixinDelegateFuns = [];
|
||||
|
||||
if (delegateFuns) {
|
||||
mixinDelegateFuns.push(...delegateFuns);
|
||||
}
|
||||
|
||||
function TargetClassWithCtors(props) {
|
||||
if (!new.target) {
|
||||
throw new TypeError();
|
||||
}
|
||||
|
||||
const self = Reflect.construct(TargetClass, [props], new.target);
|
||||
|
||||
for (const ctor of ctors) {
|
||||
ctor(self, props);
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
TargetClassWithCtors.prototype = TargetClass.prototype;
|
||||
|
||||
for (const attr in TargetClass) {
|
||||
TargetClassWithCtors[attr] = TargetClass[attr];
|
||||
}
|
||||
|
||||
|
||||
class ComponentMixinsInner extends React.Component {
|
||||
render() {
|
||||
const props = {
|
||||
...this.props,
|
||||
ref: this.props._decoratorInnerInstanceRefFn
|
||||
};
|
||||
delete props._decoratorInnerInstanceRefFn;
|
||||
|
||||
return (
|
||||
<TargetClassWithCtors {...props}/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let DecoratedInner = ComponentMixinsInner;
|
||||
|
||||
for (const mixin of mixinsClosure.values()) {
|
||||
const res = mixin.decoratorFn(DecoratedInner, TargetClassWithCtors);
|
||||
|
||||
if (res.cls) {
|
||||
DecoratedInner = res.cls;
|
||||
}
|
||||
|
||||
if (res.ctor) {
|
||||
ctors.push(res.ctor);
|
||||
}
|
||||
|
||||
if (res.delegateFuns) {
|
||||
mixinDelegateFuns.push(...res.delegateFuns);
|
||||
}
|
||||
}
|
||||
|
||||
class ComponentMixinsOuter extends React.Component {
|
||||
render() {
|
||||
let innerFn = parentProps => {
|
||||
const props = {
|
||||
...parentProps,
|
||||
_decoratorInnerInstanceRefFn: node => this._decoratorInnerInstance = node
|
||||
};
|
||||
|
||||
return <DecoratedInner {...props}/>
|
||||
}
|
||||
|
||||
for (const [propName, Context] of contexts.entries()) {
|
||||
const existingInnerFn = innerFn;
|
||||
innerFn = parentProps => (
|
||||
<Context.Consumer>
|
||||
{
|
||||
value => existingInnerFn({
|
||||
...parentProps,
|
||||
[propName]: value
|
||||
})
|
||||
}
|
||||
</Context.Consumer>
|
||||
);
|
||||
}
|
||||
|
||||
return innerFn(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
for (const fun of mixinDelegateFuns) {
|
||||
ComponentMixinsOuter.prototype[fun] = function (...args) {
|
||||
return this._decoratorInnerInstance[fun](...args);
|
||||
}
|
||||
}
|
||||
|
||||
return ComponentMixinsOuter;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,76 +1,76 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
|
||||
function handleError(that, error) {
|
||||
let errorHandled;
|
||||
if (that.errorHandler) {
|
||||
errorHandled = that.errorHandler(error);
|
||||
}
|
||||
|
||||
if (!errorHandled && that.props.parentErrorHandler) {
|
||||
errorHandled = handleError(that.props.parentErrorHandler, error);
|
||||
}
|
||||
|
||||
if (!errorHandled) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return errorHandled;
|
||||
}
|
||||
|
||||
export const ParentErrorHandlerContext = React.createContext(null);
|
||||
export const withErrorHandling = createComponentMixin([{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}], [], (TargetClass, InnerClass) => {
|
||||
/* Example of use:
|
||||
this.getFormValuesFromURL(....).catch(error => this.handleError(error));
|
||||
|
||||
It's equivalent to:
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async loadFormValues() {
|
||||
await this.getFormValuesFromURL(...);
|
||||
}
|
||||
*/
|
||||
|
||||
const originalRender = InnerClass.prototype.render;
|
||||
|
||||
InnerClass.prototype.render = function() {
|
||||
return (
|
||||
<ParentErrorHandlerContext.Provider value={this}>
|
||||
{originalRender.apply(this)}
|
||||
</ParentErrorHandlerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
InnerClass.prototype.handleError = function(error) {
|
||||
handleError(this, error);
|
||||
};
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
export function withAsyncErrorHandler(target, name, descriptor) {
|
||||
let fn = descriptor.value;
|
||||
|
||||
descriptor.value = async function () {
|
||||
try {
|
||||
await fn.apply(this, arguments)
|
||||
} catch (error) {
|
||||
handleError(this, error);
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function wrapWithAsyncErrorHandler(self, fn) {
|
||||
return async function () {
|
||||
try {
|
||||
await fn.apply(this, arguments)
|
||||
} catch (error) {
|
||||
handleError(self, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import PropTypes from 'prop-types';
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
|
||||
function handleError(that, error) {
|
||||
let errorHandled;
|
||||
if (that.errorHandler) {
|
||||
errorHandled = that.errorHandler(error);
|
||||
}
|
||||
|
||||
if (!errorHandled && that.props.parentErrorHandler) {
|
||||
errorHandled = handleError(that.props.parentErrorHandler, error);
|
||||
}
|
||||
|
||||
if (!errorHandled) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return errorHandled;
|
||||
}
|
||||
|
||||
export const ParentErrorHandlerContext = React.createContext(null);
|
||||
export const withErrorHandling = createComponentMixin([{context: ParentErrorHandlerContext, propName: 'parentErrorHandler'}], [], (TargetClass, InnerClass) => {
|
||||
/* Example of use:
|
||||
this.getFormValuesFromURL(....).catch(error => this.handleError(error));
|
||||
|
||||
It's equivalent to:
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async loadFormValues() {
|
||||
await this.getFormValuesFromURL(...);
|
||||
}
|
||||
*/
|
||||
|
||||
const originalRender = InnerClass.prototype.render;
|
||||
|
||||
InnerClass.prototype.render = function() {
|
||||
return (
|
||||
<ParentErrorHandlerContext.Provider value={this}>
|
||||
{originalRender.apply(this)}
|
||||
</ParentErrorHandlerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
InnerClass.prototype.handleError = function(error) {
|
||||
handleError(this, error);
|
||||
};
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
export function withAsyncErrorHandler(target, name, descriptor) {
|
||||
let fn = descriptor.value;
|
||||
|
||||
descriptor.value = async function () {
|
||||
try {
|
||||
await fn.apply(this, arguments)
|
||||
} catch (error) {
|
||||
handleError(this, error);
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function wrapWithAsyncErrorHandler(self, fn) {
|
||||
return async function () {
|
||||
try {
|
||||
await fn.apply(this, arguments)
|
||||
} catch (error) {
|
||||
handleError(self, error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -100,7 +100,7 @@ class Form extends Component {
|
|||
evt.preventDefault();
|
||||
|
||||
if (this.props.onSubmitAsync) {
|
||||
await owner.formHandleChangedError(async () => await this.props.onSubmitAsync(evt));
|
||||
await owner.formHandleChangedError(async () => await this.props.onSubmitAsync());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -339,7 +339,8 @@ class CheckBox extends Component {
|
|||
text: PropTypes.string.isRequired,
|
||||
label: PropTypes.string,
|
||||
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
format: PropTypes.string
|
||||
format: PropTypes.string,
|
||||
className: PropTypes.string
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -348,12 +349,12 @@ class CheckBox extends Component {
|
|||
const id = this.props.id;
|
||||
const htmlId = 'form_' + id;
|
||||
|
||||
const className = owner.addFormValidationClass('form-check-input', id);
|
||||
const inputClassName = owner.addFormValidationClass('form-check-input', id);
|
||||
|
||||
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
||||
<div className="form-group form-check my-2">
|
||||
<input className={className} type="checkbox" checked={owner.getFormValue(id)} id={htmlId} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, !owner.getFormValue(id))}/>
|
||||
<label className="form-check-label" htmlFor={htmlId}>{props.text}</label>
|
||||
<div className={`form-group form-check my-2 ${this.props.className}`}>
|
||||
<input className={inputClassName} type="checkbox" checked={owner.getFormValue(id)} id={htmlId} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, !owner.getFormValue(id))}/>
|
||||
<label className={styles.checkboxText} htmlFor={htmlId}>{props.text}</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
8
client/src/lib/helpers.js
Normal file
8
client/src/lib/helpers.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
|
||||
export function ellipsizeBreadcrumbLabel(label) {
|
||||
return ellipsize(label, 40)
|
||||
}
|
|
@ -1,72 +1,75 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import i18n
|
||||
from 'i18next';
|
||||
import {withNamespaces} from "react-i18next";
|
||||
import LanguageDetector
|
||||
from 'i18next-browser-languagedetector';
|
||||
import mailtrainConfig
|
||||
from 'mailtrainConfig';
|
||||
|
||||
import {convertToFake, getLang} from '../../../shared/langs';
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
|
||||
import lang_en_US_common from "../../../locales/en-US/common";
|
||||
|
||||
const resourcesCommon = {
|
||||
'en-US': lang_en_US_common,
|
||||
'fk-FK': convertToFake(lang_en_US_common)
|
||||
};
|
||||
|
||||
const resources = {};
|
||||
for (const lng of mailtrainConfig.enabledLanguages) {
|
||||
const langDesc = getLang(lng);
|
||||
resources[langDesc.longCode] = {
|
||||
common: resourcesCommon[langDesc.longCode]
|
||||
};
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
resources,
|
||||
|
||||
fallbackLng: mailtrainConfig.defaultLanguage,
|
||||
defaultNS: 'common',
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false // not needed for react
|
||||
},
|
||||
|
||||
react: {
|
||||
wait: true
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'locale',
|
||||
lookupCookie: 'i18nextLng',
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage', 'cookie']
|
||||
},
|
||||
|
||||
whitelist: mailtrainConfig.enabledLanguages,
|
||||
load: 'currentOnly',
|
||||
|
||||
debug: true
|
||||
})
|
||||
|
||||
|
||||
export default i18n;
|
||||
|
||||
|
||||
export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => {
|
||||
return {
|
||||
cls: withNamespaces()(TargetClass)
|
||||
}
|
||||
});
|
||||
|
||||
export function tMark(key) {
|
||||
return key;
|
||||
}
|
||||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import i18n
|
||||
from 'i18next';
|
||||
import {withNamespaces} from "react-i18next";
|
||||
import LanguageDetector
|
||||
from 'i18next-browser-languagedetector';
|
||||
import mailtrainConfig
|
||||
from 'mailtrainConfig';
|
||||
|
||||
import {convertToFake, getLang} from '../../../shared/langs';
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
|
||||
import lang_en_US_common from "../../../locales/en-US/common";
|
||||
import lang_es_ES_common from "../../../locales/es-ES/common";
|
||||
|
||||
|
||||
const resourcesCommon = {
|
||||
'en-US': lang_en_US_common,
|
||||
'es-ES': lang_es_ES_common,
|
||||
'fk-FK': convertToFake(lang_en_US_common)
|
||||
};
|
||||
|
||||
const resources = {};
|
||||
for (const lng of mailtrainConfig.enabledLanguages) {
|
||||
const langDesc = getLang(lng);
|
||||
resources[langDesc.longCode] = {
|
||||
common: resourcesCommon[langDesc.longCode]
|
||||
};
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
resources,
|
||||
|
||||
fallbackLng: mailtrainConfig.defaultLanguage,
|
||||
defaultNS: 'common',
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false // not needed for react
|
||||
},
|
||||
|
||||
react: {
|
||||
wait: true
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'locale',
|
||||
lookupCookie: 'i18nextLng',
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage', 'cookie']
|
||||
},
|
||||
|
||||
whitelist: mailtrainConfig.enabledLanguages,
|
||||
load: 'currentOnly',
|
||||
|
||||
debug: false
|
||||
});
|
||||
|
||||
|
||||
export default i18n;
|
||||
|
||||
|
||||
export const withTranslation = createComponentMixin([], [], (TargetClass, InnerClass) => {
|
||||
return {
|
||||
cls: withNamespaces()(TargetClass)
|
||||
};
|
||||
});
|
||||
|
||||
export function tMark(key) {
|
||||
return key;
|
||||
}
|
||||
|
|
81
client/src/lib/mjml-mosaico.js
Normal file
81
client/src/lib/mjml-mosaico.js
Normal file
|
@ -0,0 +1,81 @@
|
|||
'use strict';
|
||||
|
||||
import {registerDependencies, registerComponent, BodyComponent} from "mjml4-in-browser";
|
||||
|
||||
registerDependencies({
|
||||
'mj-column': ['mj-basic-component'],
|
||||
'mj-basic-component': []
|
||||
});
|
||||
|
||||
class MjBasicComponent extends BodyComponent {
|
||||
// Tell the parser that our component won't contain other mjml tags
|
||||
static endingTag = true
|
||||
|
||||
// Tells the validator which attributes are allowed for mj-layout
|
||||
static allowedAttributes = {
|
||||
'stars-color': 'color',
|
||||
'color': 'color',
|
||||
'font-size': 'unit(px)',
|
||||
'align': 'enum(left,right,center)',
|
||||
}
|
||||
|
||||
// What the name suggests. Fallback value for this.getAttribute('attribute-name').
|
||||
static defaultAttributes = {
|
||||
'stars-color': 'yellow',
|
||||
color: 'black',
|
||||
'font-size': '12px',
|
||||
'align': 'center',
|
||||
}
|
||||
|
||||
// This functions allows to define styles that can be used when rendering (see render() below)
|
||||
getStyles() {
|
||||
return {
|
||||
wrapperDiv: {
|
||||
color: this.getAttribute('stars-color'), // this.getAttribute(attrName) is the recommended way to access the attributes our component received in the mjml
|
||||
'font-size': this.getAttribute('font-size'),
|
||||
},
|
||||
contentP: {
|
||||
'text-align': this.getAttribute('align'),
|
||||
'font-size': '20px'
|
||||
},
|
||||
contentSpan: {
|
||||
color: this.getAttribute('color')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Render is the only required function in a component.
|
||||
It must return an html string.
|
||||
*/
|
||||
render() {
|
||||
return `
|
||||
<div
|
||||
${this.htmlAttributes({ // this.htmlAttributes() is the recommended way to pass attributes to html tags
|
||||
class: this.getAttribute('css-class'),
|
||||
style: 'wrapperDiv' // This will add the 'wrapperDiv' attributes from getStyles() as inline style
|
||||
})}
|
||||
>
|
||||
<p ${this.htmlAttributes({
|
||||
style: 'contentP' // This will add the 'contentP' attributes from getStyles() as inline style
|
||||
})}>
|
||||
<span>★</span>
|
||||
<span
|
||||
${this.htmlAttributes({
|
||||
style: 'contentSpan' // This will add the 'contentSpan' attributes from getStyles() as inline style
|
||||
})}
|
||||
>
|
||||
${this.getContent()}
|
||||
</span>
|
||||
<span>★</span>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function registerComponents() {
|
||||
registerComponent(MjBasicComponent)
|
||||
}
|
||||
|
|
@ -94,7 +94,7 @@ export class RestActionModalDialog extends Component {
|
|||
|
||||
return (
|
||||
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.hideModal(true)} buttons={[
|
||||
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.hideModal(true) },
|
||||
{ label: t('no'), className: 'btn-primary', onClickAsync: async () => this.hideModal(true) },
|
||||
{ label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
|
||||
]}>
|
||||
{this.props.message}
|
||||
|
|
|
@ -1,33 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import {withTranslation} from './i18n';
|
||||
import {TreeTableSelect} from './form';
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation
|
||||
])
|
||||
class NamespaceSelect extends Component {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
return (
|
||||
<TreeTableSelect id="namespace" label={t('namespace')} dataUrl="rest/namespaces-tree"/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNamespace(t, state) {
|
||||
if (!state.getIn(['namespace', 'value'])) {
|
||||
state.setIn(['namespace', 'error'], t('namespacemustBeSelected'));
|
||||
} else {
|
||||
state.setIn(['namespace', 'error'], null);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
NamespaceSelect,
|
||||
validateNamespace
|
||||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import {withTranslation} from './i18n';
|
||||
import {TreeTableSelect} from './form';
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation
|
||||
])
|
||||
class NamespaceSelect extends Component {
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
return (
|
||||
<TreeTableSelect id="namespace" label={t('namespace')} dataUrl="rest/namespaces-tree"/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function validateNamespace(t, state) {
|
||||
if (!state.getIn(['namespace', 'value'])) {
|
||||
state.setIn(['namespace', 'error'], t('namespacemustBeSelected'));
|
||||
} else {
|
||||
state.setIn(['namespace', 'error'], null);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
NamespaceSelect,
|
||||
validateNamespace
|
||||
};
|
|
@ -1,143 +1,146 @@
|
|||
'use strict';
|
||||
|
||||
import React
|
||||
from "react";
|
||||
import {withRouter} from "react-router";
|
||||
import {withErrorHandling} from "./error-handling";
|
||||
import axios
|
||||
from "../lib/axios";
|
||||
import {getUrl} from "./urls";
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
|
||||
export function needsResolve(route, nextRoute, match, nextMatch) {
|
||||
const resolve = route.resolve;
|
||||
const nextResolve = nextRoute.resolve;
|
||||
|
||||
if (Object.keys(resolve).length === Object.keys(nextResolve).length) {
|
||||
for (const key in resolve) {
|
||||
if (!(key in nextResolve) ||
|
||||
resolve[key](match.params) !== nextResolve[key](nextMatch.params)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolve(route, match) {
|
||||
const keys = Object.keys(route.resolve);
|
||||
|
||||
const promises = keys.map(key => {
|
||||
const url = route.resolve[key](match.params);
|
||||
if (url) {
|
||||
return axios.get(getUrl(url));
|
||||
} else {
|
||||
return Promise.resolve({data: null});
|
||||
}
|
||||
});
|
||||
const resolvedArr = await Promise.all(promises);
|
||||
|
||||
const resolved = {};
|
||||
for (let idx = 0; idx < keys.length; idx++) {
|
||||
resolved[keys[idx]] = resolvedArr[idx].data;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
|
||||
let routes = [];
|
||||
for (let routeKey in structure) {
|
||||
const entry = structure[routeKey];
|
||||
|
||||
let path = urlPrefix + routeKey;
|
||||
let pathWithParams = path;
|
||||
|
||||
if (entry.extraParams) {
|
||||
pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
|
||||
}
|
||||
|
||||
let entryResolve;
|
||||
if (entry.resolve) {
|
||||
entryResolve = Object.assign({}, resolve, entry.resolve);
|
||||
} else {
|
||||
entryResolve = resolve;
|
||||
}
|
||||
|
||||
let navKeys;
|
||||
const entryNavs = [];
|
||||
if (entry.navs) {
|
||||
navKeys = Object.keys(entry.navs);
|
||||
|
||||
for (const navKey of navKeys) {
|
||||
const nav = entry.navs[navKey];
|
||||
|
||||
entryNavs.push({
|
||||
title: nav.title,
|
||||
visible: nav.visible,
|
||||
link: nav.link,
|
||||
externalLink: nav.externalLink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const route = {
|
||||
path: (pathWithParams === '' ? '/' : pathWithParams),
|
||||
panelComponent: entry.panelComponent,
|
||||
panelRender: entry.panelRender,
|
||||
primaryMenuComponent: entry.primaryMenuComponent || primaryMenuComponent,
|
||||
secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent,
|
||||
title: entry.title,
|
||||
link: entry.link,
|
||||
resolve: entryResolve,
|
||||
parents,
|
||||
navs: [...navs, ...entryNavs]
|
||||
};
|
||||
|
||||
routes.push(route);
|
||||
|
||||
const childrenParents = [...parents, route];
|
||||
|
||||
if (entry.navs) {
|
||||
for (let navKeyIdx = 0; navKeyIdx < navKeys.length; navKeyIdx++) {
|
||||
const navKey = navKeys[navKeyIdx];
|
||||
const nav = entry.navs[navKey];
|
||||
|
||||
const childNavs = [...entryNavs];
|
||||
childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
|
||||
|
||||
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.children) {
|
||||
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
export const SectionContentContext = React.createContext(null);
|
||||
export const withPageHelpers = createComponentMixin([{context: SectionContentContext, propName: 'sectionContent'}], [withErrorHandling], (TargetClass, InnerClass) => {
|
||||
InnerClass.prototype.setFlashMessage = function(severity, text) {
|
||||
return this.props.sectionContent.setFlashMessage(severity, text);
|
||||
};
|
||||
|
||||
InnerClass.prototype.navigateTo = function(path) {
|
||||
return this.props.sectionContent.navigateTo(path);
|
||||
}
|
||||
|
||||
InnerClass.prototype.navigateBack = function() {
|
||||
return this.props.sectionContent.navigateBack();
|
||||
}
|
||||
|
||||
InnerClass.prototype.navigateToWithFlashMessage = function(path, severity, text) {
|
||||
return this.props.sectionContent.navigateToWithFlashMessage(path, severity, text);
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
'use strict';
|
||||
|
||||
import React
|
||||
from "react";
|
||||
import {withRouter} from "react-router";
|
||||
import {withErrorHandling} from "./error-handling";
|
||||
import axios
|
||||
from "../lib/axios";
|
||||
import {getUrl} from "./urls";
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
|
||||
export function needsResolve(route, nextRoute, match, nextMatch) {
|
||||
const resolve = route.resolve;
|
||||
const nextResolve = nextRoute.resolve;
|
||||
|
||||
// This compares whether two objects have the same content and returns TRUE if they don't
|
||||
if (Object.keys(resolve).length === Object.keys(nextResolve).length) {
|
||||
for (const key in nextResolve) {
|
||||
if (!(key in resolve) ||
|
||||
resolve[key](match.params) !== nextResolve[key](nextMatch.params)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolve(route, match) {
|
||||
const keys = Object.keys(route.resolve);
|
||||
|
||||
const promises = keys.map(key => {
|
||||
const url = route.resolve[key](match.params);
|
||||
if (url) {
|
||||
return axios.get(getUrl(url));
|
||||
} else {
|
||||
return Promise.resolve({data: null});
|
||||
}
|
||||
});
|
||||
const resolvedArr = await Promise.all(promises);
|
||||
|
||||
const resolved = {};
|
||||
for (let idx = 0; idx < keys.length; idx++) {
|
||||
resolved[keys[idx]] = resolvedArr[idx].data;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
|
||||
let routes = [];
|
||||
for (let routeKey in structure) {
|
||||
const entry = structure[routeKey];
|
||||
|
||||
let path = urlPrefix + routeKey;
|
||||
let pathWithParams = path;
|
||||
|
||||
if (entry.extraParams) {
|
||||
pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
|
||||
}
|
||||
|
||||
let entryResolve;
|
||||
if (entry.resolve) {
|
||||
entryResolve = Object.assign({}, resolve, entry.resolve);
|
||||
} else {
|
||||
entryResolve = resolve;
|
||||
}
|
||||
|
||||
let navKeys;
|
||||
const entryNavs = [];
|
||||
if (entry.navs) {
|
||||
navKeys = Object.keys(entry.navs);
|
||||
|
||||
for (const navKey of navKeys) {
|
||||
const nav = entry.navs[navKey];
|
||||
|
||||
entryNavs.push({
|
||||
title: nav.title,
|
||||
visible: nav.visible,
|
||||
link: nav.link,
|
||||
externalLink: nav.externalLink
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const route = {
|
||||
path: (pathWithParams === '' ? '/' : pathWithParams),
|
||||
panelComponent: entry.panelComponent,
|
||||
panelRender: entry.panelRender,
|
||||
primaryMenuComponent: entry.primaryMenuComponent || primaryMenuComponent,
|
||||
secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent,
|
||||
title: entry.title,
|
||||
link: entry.link,
|
||||
panelInFullScreen: entry.panelInFullScreen,
|
||||
insideIframe: entry.insideIframe,
|
||||
resolve: entryResolve,
|
||||
parents,
|
||||
navs: [...navs, ...entryNavs]
|
||||
};
|
||||
|
||||
routes.push(route);
|
||||
|
||||
const childrenParents = [...parents, route];
|
||||
|
||||
if (entry.navs) {
|
||||
for (let navKeyIdx = 0; navKeyIdx < navKeys.length; navKeyIdx++) {
|
||||
const navKey = navKeys[navKeyIdx];
|
||||
const nav = entry.navs[navKey];
|
||||
|
||||
const childNavs = [...entryNavs];
|
||||
childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
|
||||
|
||||
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.children) {
|
||||
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
export const SectionContentContext = React.createContext(null);
|
||||
export const withPageHelpers = createComponentMixin([{context: SectionContentContext, propName: 'sectionContent'}], [withErrorHandling], (TargetClass, InnerClass) => {
|
||||
InnerClass.prototype.setFlashMessage = function(severity, text) {
|
||||
return this.props.sectionContent.setFlashMessage(severity, text);
|
||||
};
|
||||
|
||||
InnerClass.prototype.navigateTo = function(path) {
|
||||
return this.props.sectionContent.navigateTo(path);
|
||||
}
|
||||
|
||||
InnerClass.prototype.navigateBack = function() {
|
||||
return this.props.sectionContent.navigateBack();
|
||||
}
|
||||
|
||||
InnerClass.prototype.navigateToWithFlashMessage = function(path, severity, text) {
|
||||
return this.props.sectionContent.navigateToWithFlashMessage(path, severity, text);
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,12 +1,12 @@
|
|||
'use strict';
|
||||
|
||||
import {getUrl} from "./urls";
|
||||
import axios from "./axios";
|
||||
|
||||
async function checkPermissions(request) {
|
||||
return await axios.post(getUrl('rest/permissions-check'), request);
|
||||
}
|
||||
|
||||
export {
|
||||
checkPermissions
|
||||
'use strict';
|
||||
|
||||
import {getUrl} from "./urls";
|
||||
import axios from "./axios";
|
||||
|
||||
async function checkPermissions(request) {
|
||||
return await axios.post(getUrl('rest/permissions-check'), request);
|
||||
}
|
||||
|
||||
export {
|
||||
checkPermissions
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
'use strict';
|
||||
|
||||
import {getUrl} from "./urls";
|
||||
|
||||
__webpack_public_path__ = getUrl('client/');
|
||||
'use strict';
|
||||
|
||||
import {getUrl} from "./urls";
|
||||
|
||||
__webpack_public_path__ = getUrl('client/');
|
||||
|
|
|
@ -1,90 +1,90 @@
|
|||
$navbarHeight: 34px;
|
||||
$editorNormalHeight: 800px !default;
|
||||
|
||||
.editor {
|
||||
.host {
|
||||
@if $editorNormalHeight {
|
||||
height: $editorNormalHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editorFullscreen {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
margin-top: $navbarHeight;
|
||||
|
||||
.navbar {
|
||||
margin-top: -$navbarHeight;
|
||||
}
|
||||
|
||||
.host {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: #f86c6b;
|
||||
width: 100%;
|
||||
height: $navbarHeight;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbarLeft {
|
||||
.logo {
|
||||
display: inline-block;
|
||||
height: $navbarHeight;
|
||||
padding: 5px 0 5px 10px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
padding: 5px 0 5px 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
color: white;
|
||||
height: $navbarHeight;
|
||||
}
|
||||
}
|
||||
|
||||
.navbarRight {
|
||||
.btn, .btnDisabled {
|
||||
display: inline-block;
|
||||
padding: 0px 15px;
|
||||
line-height: $navbarHeight;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
font-family: sans-serif;
|
||||
cursor: pointer;
|
||||
|
||||
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #c05454;
|
||||
text-decoration: none;
|
||||
|
||||
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.btnDisabled {
|
||||
cursor: default;
|
||||
|
||||
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
|
||||
color: #621d1d;
|
||||
}
|
||||
}
|
||||
}
|
||||
$navbarHeight: 34px;
|
||||
$editorNormalHeight: 800px !default;
|
||||
|
||||
.editor {
|
||||
.host {
|
||||
@if $editorNormalHeight {
|
||||
height: $editorNormalHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editorFullscreen {
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
z-index: 1000;
|
||||
background: white;
|
||||
margin-top: $navbarHeight;
|
||||
|
||||
.navbar {
|
||||
margin-top: -$navbarHeight;
|
||||
}
|
||||
|
||||
.host {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: #f86c6b;
|
||||
width: 100%;
|
||||
height: $navbarHeight;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbarLeft {
|
||||
.logo {
|
||||
display: inline-block;
|
||||
height: $navbarHeight;
|
||||
padding: 5px 0 5px 10px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
padding: 5px 0 5px 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
color: white;
|
||||
height: $navbarHeight;
|
||||
}
|
||||
}
|
||||
|
||||
.navbarRight {
|
||||
.btn, .btnDisabled {
|
||||
display: inline-block;
|
||||
padding: 0px 15px;
|
||||
line-height: $navbarHeight;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
font-family: sans-serif;
|
||||
cursor: pointer;
|
||||
|
||||
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #c05454;
|
||||
text-decoration: none;
|
||||
|
||||
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.btnDisabled {
|
||||
cursor: default;
|
||||
|
||||
&, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
|
||||
color: #621d1d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
$editorNormalHeight: false;
|
||||
@import "sandbox-common";
|
||||
|
||||
.sandbox {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
$editorNormalHeight: false;
|
||||
@import "sandbox-common";
|
||||
|
||||
.sandbox {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
|
@ -34,12 +34,16 @@ import {CodeEditorSourceType} from "./sandboxed-codeeditor-shared";
|
|||
import mjml2html
|
||||
from "mjml4-in-browser";
|
||||
|
||||
import {registerComponents} from "./mjml-mosaico";
|
||||
|
||||
import juice
|
||||
from "juice";
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
const refreshTimeout = 1000;
|
||||
|
||||
registerComponents();
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation
|
||||
])
|
||||
|
|
|
@ -1,35 +1,35 @@
|
|||
@import "sandbox-common";
|
||||
|
||||
.sandbox {
|
||||
}
|
||||
|
||||
.aceEditorWithPreview, .aceEditorWithoutPreview, .preview {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.aceEditorWithPreview {
|
||||
border-right: #e8e8e8 solid 2px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.aceEditorWithoutPreview {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview {
|
||||
border-left: #e8e8e8 solid 2px;
|
||||
width: 50%;
|
||||
left: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0px none;
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
@import "sandbox-common";
|
||||
|
||||
.sandbox {
|
||||
}
|
||||
|
||||
.aceEditorWithPreview, .aceEditorWithoutPreview, .preview {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.aceEditorWithPreview {
|
||||
border-right: #e8e8e8 solid 2px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.aceEditorWithoutPreview {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview {
|
||||
border-left: #e8e8e8 solid 2px;
|
||||
width: 50%;
|
||||
left: 50%;
|
||||
overflow: hidden;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0px none;
|
||||
|
||||
body {
|
||||
margin: 0px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,18 +1,18 @@
|
|||
@import "sandbox-common";
|
||||
|
||||
:global .grapesjs-body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
:global .gjs-editor-cont {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:global .gjs-devices-c .gjs-devices {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
:global .gjs-pn-devices-c, :global .gjs-pn-views {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@import "sandbox-common";
|
||||
|
||||
:global .grapesjs-body {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
:global .gjs-editor-cont {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:global .gjs-devices-c .gjs-devices {
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
:global .gjs-pn-devices-c, :global .gjs-pn-views {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
|
|
|
@ -82,6 +82,12 @@ class MosaicoSandbox extends Component {
|
|||
};
|
||||
});
|
||||
|
||||
// Custom convertedUrl (https://github.com/voidlabs/mosaico/blob/a359e263f1af5cf05e2c2d56c771732f2ef6c8c6/src/js/app.js#L42)
|
||||
// which does not complain about mismatch of domains between TRUSTED and PUBLIC
|
||||
plugins.push(viewModel => {
|
||||
ko.bindingHandlers.wysiwygSrc.convertedUrl = (src, method, width, height) => getTrustedUrl(`mosaico/img?src=${encodeURIComponent(src)}&method=${encodeURIComponent(method)}¶ms=${width},${height}`);
|
||||
});
|
||||
|
||||
plugins.unshift(vm => {
|
||||
// This is an override of the default paths in Mosaico
|
||||
vm.logoPath = getTrustedUrl('static/mosaico/img/mosaico32.png');
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
@import "sandbox-common";
|
||||
|
||||
:global .mo-standalone {
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
@import "sandbox-common";
|
||||
|
||||
:global .mo-standalone {
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
|
|
@ -1,176 +1,185 @@
|
|||
@import "../scss/variables.scss";
|
||||
|
||||
.toolbar {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.form { // This is here to give the styles below higher priority than Bootstrap has
|
||||
:global .DayPicker {
|
||||
border: $input-border-width solid $input-border-color;
|
||||
border-radius: $input-border-radius;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
}
|
||||
|
||||
:global .form-horizontal .control-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global .form-control[disabled] {
|
||||
cursor: default;
|
||||
background-color: #eeeeee;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global .ace_editor {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.buttonRow:last-child {
|
||||
// This is to move Save/Delete buttons a bit down
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.staticFormGroup {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.dayPickerWrapper {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
}
|
||||
|
||||
.buttonRow > * {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.buttonRow > *:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.formDisabled {
|
||||
background-color: #eeeeee;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.formStatus {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.actionLinks > * {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actionLinks > *:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.tableSelectDropdown {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tableSelectTable.tableSelectTableHidden {
|
||||
visibility: hidden;
|
||||
height: 0px;
|
||||
margin-top: -15px;
|
||||
}
|
||||
|
||||
.tableSelectDropdown input[readonly] {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:global h3.legend {
|
||||
font-size: 21px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tertiaryNav {
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
align-self: center;
|
||||
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
|
||||
:global .nav-item .nav-link {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.colorPickerSwatchWrapper {
|
||||
padding: 7px;
|
||||
background: #fff;
|
||||
border: 1px solid #AAB2BD;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
.colorPickerSwatchColor {
|
||||
width: 60px;
|
||||
height: 18px;
|
||||
borderRadius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.colorPickerWrapper {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dropZone{
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 3px;
|
||||
margin-top: 3px;
|
||||
border: 2px solid #E6E9ED;
|
||||
border-radius: 5px;
|
||||
background-color: #FAFAD2;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
color: #808080;
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropZoneActive{
|
||||
border-color: #90EE90;
|
||||
color: #000;
|
||||
background-color: #DDFFDD;
|
||||
}
|
||||
|
||||
|
||||
.untrustedContent {
|
||||
border: 0px none;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.withElementInFullscreen {
|
||||
height: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iconDisabled {
|
||||
color: $link-color;
|
||||
text-decoration: $link-decoration;
|
||||
}
|
||||
|
||||
.dependenciesList {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
|
||||
:global .modal-dialog {
|
||||
@media (min-width: 768px) {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
|
||||
@import "../scss/variables.scss";
|
||||
|
||||
.toolbar {
|
||||
float: right;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form { // This is here to give the styles below higher priority than Bootstrap has
|
||||
:global .DayPicker {
|
||||
border: $input-border-width solid $input-border-color;
|
||||
border-radius: $input-border-radius;
|
||||
padding: $input-padding-y $input-padding-x;
|
||||
}
|
||||
|
||||
:global .form-horizontal .control-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global .form-control[disabled] {
|
||||
cursor: default;
|
||||
background-color: #eeeeee;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global .ace_editor {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.buttonRow:last-child {
|
||||
// This is to move Save/Delete buttons a bit down
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.staticFormGroup {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.dayPickerWrapper {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
}
|
||||
|
||||
.buttonRow > * {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.buttonRow > *:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.formDisabled {
|
||||
background-color: #eeeeee;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.formStatus {
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.dataTableTable {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.actionLinks > * {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.actionLinks > *:last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.tableSelectDropdown {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tableSelectTable.tableSelectTableHidden {
|
||||
display: none;
|
||||
height: 0px;
|
||||
margin-top: -15px;
|
||||
}
|
||||
|
||||
.tableSelectDropdown input[readonly] {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
:global h3.legend {
|
||||
font-size: 21px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tertiaryNav {
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
align-self: center;
|
||||
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
|
||||
:global .nav-item .nav-link {
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.colorPickerSwatchWrapper {
|
||||
padding: 7px;
|
||||
background: #fff;
|
||||
border: 1px solid #AAB2BD;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
.colorPickerSwatchColor {
|
||||
width: 60px;
|
||||
height: 18px;
|
||||
borderRadius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.colorPickerWrapper {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.checkboxText{
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.dropZone{
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 3px;
|
||||
margin-top: 3px;
|
||||
border: 2px solid #E6E9ED;
|
||||
border-radius: 5px;
|
||||
background-color: #FAFAD2;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
color: #808080;
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropZoneActive{
|
||||
border-color: #90EE90;
|
||||
color: #000;
|
||||
background-color: #DDFFDD;
|
||||
}
|
||||
|
||||
|
||||
.untrustedContent {
|
||||
border: 0px none;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.withElementInFullscreen {
|
||||
height: 0px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iconDisabled {
|
||||
color: $link-color;
|
||||
text-decoration: $link-decoration;
|
||||
}
|
||||
|
||||
.dependenciesList {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
|
||||
:global .modal-dialog {
|
||||
@media (min-width: 768px) {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
max-width: 900px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -276,7 +276,11 @@ class Table extends Component {
|
|||
|
||||
const dtOptions = {
|
||||
columns,
|
||||
pageLength: this.props.pageLength
|
||||
pageLength: this.props.pageLength,
|
||||
dom: // This overrides Bootstrap 4 settings. It may need to be updated if there are updates in the DataTables Bootstrap 4 plugin.
|
||||
"<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
|
||||
"<'row'<'col-sm-12'<'" + styles.dataTableTable + "'tr>>>" +
|
||||
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>"
|
||||
};
|
||||
|
||||
const self = this;
|
||||
|
|
|
@ -65,8 +65,8 @@ class TreeTable extends Component {
|
|||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async loadData(dataUrl) {
|
||||
const response = await axios.get(getUrl(dataUrl));
|
||||
async loadData() {
|
||||
const response = await axios.get(getUrl(this.props.dataUrl));
|
||||
const treeData = response.data;
|
||||
|
||||
for (const root of treeData) {
|
||||
|
@ -95,19 +95,9 @@ class TreeTable extends Component {
|
|||
className: PropTypes.string
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.data) {
|
||||
this.setState({
|
||||
treeData: nextProps.data
|
||||
});
|
||||
} else if (nextProps.dataUrl && this.props.dataUrl !== nextProps.dataUrl) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.loadData(next.props.dataUrl);
|
||||
}
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return this.props.selection !== nextProps.selection || this.state.treeData != nextState.treeData || this.props.className !== nextProps.className;
|
||||
return this.props.selection !== nextProps.selection || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl ||
|
||||
this.state.treeData != nextState.treeData || this.props.className !== nextProps.className;
|
||||
}
|
||||
|
||||
// XSS protection
|
||||
|
@ -129,7 +119,7 @@ class TreeTable extends Component {
|
|||
componentDidMount() {
|
||||
if (!this.props.data && this.props.dataUrl) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.loadData(this.props.dataUrl);
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
let createNodeFn;
|
||||
|
@ -221,6 +211,15 @@ class TreeTable extends Component {
|
|||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (this.props.data) {
|
||||
this.setState({
|
||||
treeData: this.props.data
|
||||
});
|
||||
} else if (this.props.dataUrl && prevProps.dataUrl !== this.props.dataUrl) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
if (this.props.selection !== prevProps.selection || this.state.treeData != prevState.treeData) {
|
||||
if (this.state.treeData != prevState.treeData) {
|
||||
this.tree.reload(this.sanitizeTreeData(this.state.treeData));
|
||||
|
|
|
@ -1,92 +1,92 @@
|
|||
@import "../scss/variables.scss";
|
||||
|
||||
:global {
|
||||
|
||||
.mt-treetable-container .fancytree-container {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mt-treetable-container span.fancytree-expander {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td,
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active:hover>td,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active:hover span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-expander,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container>tbody>tr.fancytree-active>td {
|
||||
outline: 0px none;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.mt-treetable-container span.fancytree-node span.fancytree-expander:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mt-treetable-container {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.mt-treetable-container>table.fancytree-ext-table {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-noheader>.table>tbody>tr>td {
|
||||
border-top: 0px none;
|
||||
}
|
||||
|
||||
.mt-treetable-container .mt-treetable-title {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.form-group .mt-treetable-container {
|
||||
border: $input-border-width solid $input-border-color;
|
||||
border-radius: $input-border-radius;
|
||||
padding-top: $input-padding-y;
|
||||
padding-bottom: $input-padding-y;
|
||||
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
-webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;
|
||||
-o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
|
||||
transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
|
||||
}
|
||||
|
||||
.form-group .mt-treetable-container.is-valid {
|
||||
border-color: $form-feedback-valid-color;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
}
|
||||
|
||||
.form-group .mt-treetable-container.is-invalid {
|
||||
border-color: $form-feedback-invalid-color;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
}
|
||||
|
||||
.mt-treetable-container .table td {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
}
|
||||
@import "../scss/variables.scss";
|
||||
|
||||
:global {
|
||||
|
||||
.mt-treetable-container .fancytree-container {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mt-treetable-container span.fancytree-expander {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td,
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active:hover>td,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active:hover span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-expander,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
|
||||
.mt-treetable-container.mt-treetable-inactivable .fancytree-container>tbody>tr.fancytree-active>td {
|
||||
outline: 0px none;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.mt-treetable-container span.fancytree-node span.fancytree-expander:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.mt-treetable-container {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.mt-treetable-container>table.fancytree-ext-table {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.mt-treetable-container.mt-treetable-noheader>.table>tbody>tr>td {
|
||||
border-top: 0px none;
|
||||
}
|
||||
|
||||
.mt-treetable-container .mt-treetable-title {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.form-group .mt-treetable-container {
|
||||
border: $input-border-width solid $input-border-color;
|
||||
border-radius: $input-border-radius;
|
||||
padding-top: $input-padding-y;
|
||||
padding-bottom: $input-padding-y;
|
||||
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
-webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;
|
||||
-o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
|
||||
transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
|
||||
}
|
||||
|
||||
.form-group .mt-treetable-container.is-valid {
|
||||
border-color: $form-feedback-valid-color;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
}
|
||||
|
||||
.form-group .mt-treetable-container.is-invalid {
|
||||
border-color: $form-feedback-invalid-color;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
|
||||
}
|
||||
|
||||
.mt-treetable-container .table td {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ export class UntrustedContentHost extends Component {
|
|||
this.contentNodeIsLoaded = false;
|
||||
|
||||
this.state = {
|
||||
hasAccessToken: false,
|
||||
hasAccessToken: false
|
||||
};
|
||||
|
||||
this.receiveMessageHandler = ::this.receiveMessage;
|
||||
|
@ -175,7 +175,8 @@ export class UntrustedContentHost extends Component {
|
|||
|
||||
render() {
|
||||
return (
|
||||
<iframe className={styles.untrustedContent + ' ' + this.props.className} ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}> </iframe>
|
||||
// The 40 px below corresponds to the height in .sandbox-loading-message
|
||||
<iframe className={styles.untrustedContent + ' ' + this.props.className} height="40px" ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}></iframe>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -218,10 +219,10 @@ export class UntrustedContentRoot extends Component {
|
|||
async receiveMessage(evt) {
|
||||
const msg = evt.data;
|
||||
|
||||
if (msg.type === 'initAvailable' && !this.state.initialized) {
|
||||
if (msg.type === 'initAvailable') {
|
||||
this.sendMessage('initNeeded');
|
||||
|
||||
} else if (msg.type === 'init' && !this.state.initialized) {
|
||||
} else if (msg.type === 'init') {
|
||||
setRestrictedAccessToken(msg.data.accessToken);
|
||||
this.setState({
|
||||
initialized: true,
|
||||
|
@ -255,7 +256,7 @@ export class UntrustedContentRoot extends Component {
|
|||
return this.props.render(this.state.contentProps);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
<div className="sandbox-loading-message">
|
||||
{t('loading-1')}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,60 +1,60 @@
|
|||
'use strict';
|
||||
|
||||
import {anonymousRestrictedAccessToken} from '../../../shared/urls';
|
||||
import {AppType} from '../../../shared/app';
|
||||
import mailtrainConfig from "mailtrainConfig";
|
||||
import i18n from './i18n';
|
||||
|
||||
let restrictedAccessToken = anonymousRestrictedAccessToken;
|
||||
|
||||
function setRestrictedAccessToken(token) {
|
||||
restrictedAccessToken = token;
|
||||
}
|
||||
|
||||
function getTrustedUrl(path) {
|
||||
return mailtrainConfig.trustedUrlBase + (path || '');
|
||||
}
|
||||
|
||||
function getSandboxUrl(path, customRestrictedAccessToken) {
|
||||
const localRestrictedAccessToken = customRestrictedAccessToken || restrictedAccessToken;
|
||||
return mailtrainConfig.sandboxUrlBase + localRestrictedAccessToken + '/' + (path || '');
|
||||
}
|
||||
|
||||
function getPublicUrl(path, opts) {
|
||||
const url = new URL(path || '', mailtrainConfig.publicUrlBase);
|
||||
|
||||
if (opts && opts.withLocale) {
|
||||
url.searchParams.append('locale', i18n.language);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function getUrl(path) {
|
||||
if (mailtrainConfig.appType === AppType.TRUSTED) {
|
||||
return getTrustedUrl(path);
|
||||
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
|
||||
return getSandboxUrl(path);
|
||||
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
|
||||
return getPublicUrl(path);
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseDir() {
|
||||
if (mailtrainConfig.appType === AppType.TRUSTED) {
|
||||
return mailtrainConfig.trustedUrlBaseDir;
|
||||
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
|
||||
return mailtrainConfig.sandboxUrlBaseDir + restrictedAccessToken;
|
||||
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
|
||||
return mailtrainConfig.publicUrlBaseDir;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getTrustedUrl,
|
||||
getSandboxUrl,
|
||||
getPublicUrl,
|
||||
getUrl,
|
||||
getBaseDir,
|
||||
setRestrictedAccessToken
|
||||
'use strict';
|
||||
|
||||
import {anonymousRestrictedAccessToken} from '../../../shared/urls';
|
||||
import {AppType} from '../../../shared/app';
|
||||
import mailtrainConfig from "mailtrainConfig";
|
||||
import i18n from './i18n';
|
||||
|
||||
let restrictedAccessToken = anonymousRestrictedAccessToken;
|
||||
|
||||
function setRestrictedAccessToken(token) {
|
||||
restrictedAccessToken = token;
|
||||
}
|
||||
|
||||
function getTrustedUrl(path) {
|
||||
return mailtrainConfig.trustedUrlBase + (path || '');
|
||||
}
|
||||
|
||||
function getSandboxUrl(path, customRestrictedAccessToken) {
|
||||
const localRestrictedAccessToken = customRestrictedAccessToken || restrictedAccessToken;
|
||||
return mailtrainConfig.sandboxUrlBase + localRestrictedAccessToken + '/' + (path || '');
|
||||
}
|
||||
|
||||
function getPublicUrl(path, opts) {
|
||||
const url = new URL(path || '', mailtrainConfig.publicUrlBase);
|
||||
|
||||
if (opts && opts.withLocale) {
|
||||
url.searchParams.append('locale', i18n.language);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function getUrl(path) {
|
||||
if (mailtrainConfig.appType === AppType.TRUSTED) {
|
||||
return getTrustedUrl(path);
|
||||
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
|
||||
return getSandboxUrl(path);
|
||||
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
|
||||
return getPublicUrl(path);
|
||||
}
|
||||
}
|
||||
|
||||
function getBaseDir() {
|
||||
if (mailtrainConfig.appType === AppType.TRUSTED) {
|
||||
return mailtrainConfig.trustedUrlBaseDir;
|
||||
} else if (mailtrainConfig.appType === AppType.SANDBOXED) {
|
||||
return mailtrainConfig.sandboxUrlBaseDir + restrictedAccessToken;
|
||||
} else if (mailtrainConfig.appType === AppType.PUBLIC) {
|
||||
return mailtrainConfig.publicUrlBaseDir;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
getTrustedUrl,
|
||||
getSandboxUrl,
|
||||
getPublicUrl,
|
||||
getUrl,
|
||||
getBaseDir,
|
||||
setRestrictedAccessToken
|
||||
}
|
|
@ -60,13 +60,16 @@ export default class CUD extends Component {
|
|||
action: PropTypes.string.isRequired,
|
||||
entity: PropTypes.object
|
||||
}
|
||||
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.form = data.default_form ? 'custom' : 'default';
|
||||
data.listunsubscribe_disabled = !!data.listunsubscribe_disabled;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.form = data.default_form ? 'custom' : 'default';
|
||||
data.listunsubscribe_disabled = !!data.listunsubscribe_disabled;
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
name: '',
|
||||
|
@ -110,7 +113,7 @@ export default class CUD extends Component {
|
|||
validateNamespace(t, state);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -125,7 +128,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
if (data.form === 'default') {
|
||||
data.default_form = null;
|
||||
}
|
||||
|
@ -136,8 +139,22 @@ export default class CUD extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/lists', 'success', t('listSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/lists', 'success', t('List updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/lists/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('List updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/lists', 'success', t('List created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/lists/${submitResult}/edit`, 'success', t('List created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -260,7 +277,7 @@ export default class CUD extends Component {
|
|||
<Dropdown id="form" label={t('forms')} options={formsOptions} help={t('webAndEmailFormsAndTemplatesUsedIn')}/>
|
||||
|
||||
{this.getFormValue('form') === 'custom' &&
|
||||
<TableSelect id="default_form" label={t('customForms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans i18nKey="theCustomFormUsedForThisListYouCanCreate">The custom form used for this list. You can create a form <a href={`/lists/forms/create/${this.props.entity.id}`}>here</a>.</Trans>}/>
|
||||
<TableSelect id="default_form" label={t('customForms')} withHeader dropdown dataUrl='rest/forms-table' columns={customFormsColumns} selectionLabelIndex={1} help={<Trans i18nKey="theCustomFormUsedForThisListYouCanCreate">The custom form used for this list. You can create a form <a href={`/lists/forms/create`}>here</a>.</Trans>}/>
|
||||
}
|
||||
|
||||
<CheckBox id="public_subscribe" label={t('subscription')} text={t('allowPublicUsersToSubscribeThemselves')}/>
|
||||
|
@ -270,7 +287,8 @@ export default class CUD extends Component {
|
|||
<CheckBox id="listunsubscribe_disabled" label={t('unsubscribeHeader')} text={t('doNotSendListUnsubscribeHeaders')}/>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -87,50 +87,52 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.settings = data.settings || {};
|
||||
|
||||
if (data.default_value === null) {
|
||||
data.default_value = '';
|
||||
}
|
||||
|
||||
data.isInGroup = data.group !== null;
|
||||
|
||||
data.enumOptions = '';
|
||||
data.dateFormat = DateFormat.EUR;
|
||||
data.renderTemplate = '';
|
||||
|
||||
switch (data.type) {
|
||||
case 'checkbox-grouped':
|
||||
case 'radio-grouped':
|
||||
case 'dropdown-grouped':
|
||||
case 'json':
|
||||
data.renderTemplate = data.settings.renderTemplate;
|
||||
break;
|
||||
|
||||
case 'radio-enum':
|
||||
case 'dropdown-enum':
|
||||
data.enumOptions = this.renderEnumOptions(data.settings.options);
|
||||
data.renderTemplate = data.settings.renderTemplate;
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
case 'birthday':
|
||||
data.dateFormat = data.settings.dateFormat;
|
||||
break;
|
||||
|
||||
case 'option':
|
||||
data.checkedLabel = data.isInGroup ? '' : data.settings.checkedLabel;
|
||||
data.uncheckedLabel = data.isInGroup ? '' : data.settings.uncheckedLabel;
|
||||
break;
|
||||
}
|
||||
|
||||
data.orderListBefore = data.orderListBefore.toString();
|
||||
data.orderSubscribeBefore = data.orderSubscribeBefore.toString();
|
||||
data.orderManageBefore = data.orderManageBefore.toString();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.settings = data.settings || {};
|
||||
|
||||
if (data.default_value === null) {
|
||||
data.default_value = '';
|
||||
}
|
||||
|
||||
data.isInGroup = data.group !== null;
|
||||
|
||||
data.enumOptions = '';
|
||||
data.dateFormat = DateFormat.EUR;
|
||||
data.renderTemplate = '';
|
||||
|
||||
switch (data.type) {
|
||||
case 'checkbox-grouped':
|
||||
case 'radio-grouped':
|
||||
case 'dropdown-grouped':
|
||||
case 'json':
|
||||
data.renderTemplate = data.settings.renderTemplate;
|
||||
break;
|
||||
|
||||
case 'radio-enum':
|
||||
case 'dropdown-enum':
|
||||
data.enumOptions = this.renderEnumOptions(data.settings.options);
|
||||
data.renderTemplate = data.settings.renderTemplate;
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
case 'birthday':
|
||||
data.dateFormat = data.settings.dateFormat;
|
||||
break;
|
||||
|
||||
case 'option':
|
||||
data.checkedLabel = data.isInGroup ? '' : data.settings.checkedLabel;
|
||||
data.uncheckedLabel = data.isInGroup ? '' : data.settings.uncheckedLabel;
|
||||
break;
|
||||
}
|
||||
|
||||
data.orderListBefore = data.orderListBefore.toString();
|
||||
data.orderSubscribeBefore = data.orderSubscribeBefore.toString();
|
||||
data.orderManageBefore = data.orderManageBefore.toString();
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
|
@ -248,7 +250,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -264,7 +266,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
if (data.default_value.trim() === '') {
|
||||
data.default_value = null;
|
||||
}
|
||||
|
@ -317,8 +319,22 @@ export default class CUD extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('fieldSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('Field updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/fields/${this.props.list.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Field updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('Field created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields/${submitResult}/edit`, 'success', t('Field created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -508,7 +524,8 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/fields/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -1,55 +1,55 @@
|
|||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import {Fieldset, InputField} from "../../lib/form";
|
||||
|
||||
export function getFieldTypes(t) {
|
||||
|
||||
const fieldTypes = {
|
||||
text: {
|
||||
label: t('text'),
|
||||
},
|
||||
website: {
|
||||
label: t('website'),
|
||||
},
|
||||
longtext: {
|
||||
label: t('multilineText'),
|
||||
},
|
||||
gpg: {
|
||||
label: t('gpgPublicKey'),
|
||||
},
|
||||
number: {
|
||||
label: t('number'),
|
||||
},
|
||||
'checkbox-grouped': {
|
||||
label: t('checkboxesFromOptionFields'),
|
||||
},
|
||||
'radio-grouped': {
|
||||
label: t('radioButtonsFromOptionFields')
|
||||
},
|
||||
'dropdown-grouped': {
|
||||
label: t('dropDownFromOptionFields')
|
||||
},
|
||||
'radio-enum': {
|
||||
label: t('radioButtonsEnumerated')
|
||||
},
|
||||
'dropdown-enum': {
|
||||
label: t('dropDownEnumerated')
|
||||
},
|
||||
'date': {
|
||||
label: t('date')
|
||||
},
|
||||
'birthday': {
|
||||
label: t('birthday')
|
||||
},
|
||||
json: {
|
||||
label: t('jsonValueForCustomRendering')
|
||||
},
|
||||
option: {
|
||||
label: t('option')
|
||||
}
|
||||
};
|
||||
|
||||
return fieldTypes;
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import {Fieldset, InputField} from "../../lib/form";
|
||||
|
||||
export function getFieldTypes(t) {
|
||||
|
||||
const fieldTypes = {
|
||||
text: {
|
||||
label: t('text'),
|
||||
},
|
||||
website: {
|
||||
label: t('website'),
|
||||
},
|
||||
longtext: {
|
||||
label: t('multilineText'),
|
||||
},
|
||||
gpg: {
|
||||
label: t('gpgPublicKey'),
|
||||
},
|
||||
number: {
|
||||
label: t('number'),
|
||||
},
|
||||
'checkbox-grouped': {
|
||||
label: t('checkboxesFromOptionFields'),
|
||||
},
|
||||
'radio-grouped': {
|
||||
label: t('radioButtonsFromOptionFields')
|
||||
},
|
||||
'dropdown-grouped': {
|
||||
label: t('dropDownFromOptionFields')
|
||||
},
|
||||
'radio-enum': {
|
||||
label: t('radioButtonsEnumerated')
|
||||
},
|
||||
'dropdown-enum': {
|
||||
label: t('dropDownEnumerated')
|
||||
},
|
||||
'date': {
|
||||
label: t('date')
|
||||
},
|
||||
'birthday': {
|
||||
label: t('birthday')
|
||||
},
|
||||
json: {
|
||||
label: t('jsonValueForCustomRendering')
|
||||
},
|
||||
option: {
|
||||
label: t('option')
|
||||
}
|
||||
};
|
||||
|
||||
return fieldTypes;
|
||||
}
|
||||
|
||||
|
|
|
@ -302,19 +302,23 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
|
||||
componentDidMount() {
|
||||
function supplyDefaults(data) {
|
||||
for (const key in mailtrainConfig.defaultCustomFormValues) {
|
||||
if (!data[key]) {
|
||||
data[key] = mailtrainConfig.defaultCustomFormValues[key];
|
||||
}
|
||||
supplyDefaults(data) {
|
||||
for (const key in mailtrainConfig.defaultCustomFormValues) {
|
||||
if (!data[key]) {
|
||||
data[key] = mailtrainConfig.defaultCustomFormValues[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
this.supplyDefaults(data);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
this.getFormValuesMutator(data);
|
||||
data.selectedTemplate = 'layout';
|
||||
supplyDefaults(data);
|
||||
});
|
||||
|
||||
} else {
|
||||
|
@ -324,7 +328,7 @@ export default class CUD extends Component {
|
|||
selectedTemplate: 'layout',
|
||||
namespace: mailtrainConfig.user.namespace
|
||||
};
|
||||
supplyDefaults(data);
|
||||
this.supplyDefaults(data);
|
||||
|
||||
this.populateFormValues(data);
|
||||
}
|
||||
|
@ -370,7 +374,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -385,13 +389,27 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
delete data.selectedTemplate;
|
||||
delete data.previewList;
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/lists/forms', 'success', t('formsSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/lists/forms', 'success', t('Custom forms updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/forms/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Custom forms updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/lists/forms', 'success', t('Custom forms created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/lists/forms/${submitResult}/edit`, 'success', t('Custom forms created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -410,6 +428,7 @@ export default class CUD extends Component {
|
|||
const response = await axios.post(getUrl('rest/forms-preview'), data);
|
||||
|
||||
this.setState({
|
||||
previewKey: formKey,
|
||||
previewContents: response.data.content,
|
||||
previewLabel: this.templateSettings[formKey].label
|
||||
});
|
||||
|
@ -504,10 +523,15 @@ export default class CUD extends Component {
|
|||
{this.state.previewContents &&
|
||||
<div className={this.state.previewFullscreen ? formsStyles.editorFullscreen : formsStyles.editor}>
|
||||
<div className={formsStyles.navbar}>
|
||||
{this.state.fullscreen && <img className={formsStyles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
|
||||
<div className={formsStyles.title}>{t('formPreview') + ' ' + this.state.previewLabel}</div>
|
||||
<a className={formsStyles.btn} onClick={() => this.setState({previewContents: null, previewFullscreen: false})}><Icon icon="window-close"/></a>
|
||||
<a className={formsStyles.btn} onClick={() => this.setState({previewFullscreen: !this.state.previewFullscreen})}><Icon icon="window-maximize"/></a>
|
||||
<div className={formsStyles.navbarLeft}>
|
||||
{this.state.fullscreen && <img className={formsStyles.logo} src={getTrustedUrl('static/mailtrain-notext.png')}/>}
|
||||
<div className={formsStyles.title}>{t('formPreview') + ' ' + this.state.previewLabel}</div>
|
||||
</div>
|
||||
<div className={formsStyles.navbarRight}>
|
||||
<a className={formsStyles.btn} onClick={() => this.preview(this.state.previewKey)} title={t('Refresh')}><Icon icon="sync-alt"/></a>
|
||||
<a className={formsStyles.btn} onClick={() => this.setState({previewFullscreen: !this.state.previewFullscreen})} title={t('Maximize editor')}><Icon icon="window-maximize"/></a>
|
||||
<a className={formsStyles.btn} onClick={() => this.setState({previewContents: null, previewFullscreen: false})} title={t('Close preview')}><Icon icon="window-close"/></a>
|
||||
</div>
|
||||
</div>
|
||||
<iframe className={formsStyles.host} src={"data:text/html;charset=utf-8," + encodeURIComponent(this.state.previewContents)}></iframe>
|
||||
</div>
|
||||
|
@ -524,7 +548,8 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/forms/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
$editorNormalHeight: 400px;
|
||||
@import "../../lib/sandbox-common";
|
||||
|
||||
.editor {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.host {
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
$editorNormalHeight: 400px;
|
||||
@import "../../lib/sandbox-common";
|
||||
|
||||
.editor {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.host {
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -92,7 +92,7 @@ export default class Status extends Component {
|
|||
const columns = [
|
||||
{ data: 1, title: t('row') },
|
||||
{ data: 2, title: t('email') },
|
||||
{ data: 3, title: t('reason') }
|
||||
{ data: 3, title: t('reason'), render: data => t(...JSON.parse(data)) }
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
@ -150,7 +150,7 @@ export default class Status extends Component {
|
|||
}
|
||||
|
||||
actions.push({
|
||||
label: <Icon icon="eye-open" title={t('runStatus')}/>,
|
||||
label: <Icon icon="eye" title={t('runStatus')}/>,
|
||||
link: `/lists/${this.props.list.id}/imports/${this.props.entity.id}/status/${data[0]}`
|
||||
});
|
||||
|
||||
|
|
|
@ -1,46 +1,46 @@
|
|||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import {ImportSource, MappingType, ImportStatus, RunStatus} from '../../../../shared/imports';
|
||||
|
||||
export function getImportLabels(t) {
|
||||
|
||||
const importSourceLabels = {
|
||||
[ImportSource.CSV_FILE]: t('csvFile'),
|
||||
[ImportSource.LIST]: t('list'),
|
||||
};
|
||||
|
||||
const importStatusLabels = {
|
||||
[ImportStatus.PREP_SCHEDULED]: t('created'),
|
||||
[ImportStatus.PREP_RUNNING]: t('preparing'),
|
||||
[ImportStatus.PREP_STOPPING]: t('stopping'),
|
||||
[ImportStatus.PREP_FINISHED]: t('ready'),
|
||||
[ImportStatus.PREP_FAILED]: t('preparationFailed'),
|
||||
[ImportStatus.RUN_SCHEDULED]: t('scheduled'),
|
||||
[ImportStatus.RUN_RUNNING]: t('running'),
|
||||
[ImportStatus.RUN_STOPPING]: t('stopping'),
|
||||
[ImportStatus.RUN_FINISHED]: t('finished'),
|
||||
[ImportStatus.RUN_FAILED]: t('failed')
|
||||
};
|
||||
|
||||
const runStatusLabels = {
|
||||
[RunStatus.SCHEDULED]: t('starting'),
|
||||
[RunStatus.RUNNING]: t('running'),
|
||||
[RunStatus.STOPPING]: t('stopping'),
|
||||
[RunStatus.FINISHED]: t('finished'),
|
||||
[RunStatus.FAILED]: t('failed')
|
||||
};
|
||||
|
||||
const mappingTypeLabels = {
|
||||
[MappingType.BASIC_SUBSCRIBE]: t('basicImportOfSubscribers'),
|
||||
[MappingType.BASIC_UNSUBSCRIBE]: t('unsubscribeEmails'),
|
||||
}
|
||||
|
||||
return {
|
||||
importStatusLabels,
|
||||
mappingTypeLabels,
|
||||
importSourceLabels,
|
||||
runStatusLabels
|
||||
};
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import {ImportSource, MappingType, ImportStatus, RunStatus} from '../../../../shared/imports';
|
||||
|
||||
export function getImportLabels(t) {
|
||||
|
||||
const importSourceLabels = {
|
||||
[ImportSource.CSV_FILE]: t('csvFile'),
|
||||
[ImportSource.LIST]: t('list'),
|
||||
};
|
||||
|
||||
const importStatusLabels = {
|
||||
[ImportStatus.PREP_SCHEDULED]: t('created'),
|
||||
[ImportStatus.PREP_RUNNING]: t('preparing'),
|
||||
[ImportStatus.PREP_STOPPING]: t('stopping'),
|
||||
[ImportStatus.PREP_FINISHED]: t('ready'),
|
||||
[ImportStatus.PREP_FAILED]: t('preparationFailed'),
|
||||
[ImportStatus.RUN_SCHEDULED]: t('scheduled'),
|
||||
[ImportStatus.RUN_RUNNING]: t('running'),
|
||||
[ImportStatus.RUN_STOPPING]: t('stopping'),
|
||||
[ImportStatus.RUN_FINISHED]: t('finished'),
|
||||
[ImportStatus.RUN_FAILED]: t('failed')
|
||||
};
|
||||
|
||||
const runStatusLabels = {
|
||||
[RunStatus.SCHEDULED]: t('starting'),
|
||||
[RunStatus.RUNNING]: t('running'),
|
||||
[RunStatus.STOPPING]: t('stopping'),
|
||||
[RunStatus.FINISHED]: t('finished'),
|
||||
[RunStatus.FAILED]: t('failed')
|
||||
};
|
||||
|
||||
const mappingTypeLabels = {
|
||||
[MappingType.BASIC_SUBSCRIBE]: t('basicImportOfSubscribers'),
|
||||
[MappingType.BASIC_UNSUBSCRIBE]: t('unsubscribeEmails'),
|
||||
}
|
||||
|
||||
return {
|
||||
importStatusLabels,
|
||||
mappingTypeLabels,
|
||||
importSourceLabels,
|
||||
runStatusLabels
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import ImportsStatus from './imports/Status';
|
|||
import ImportRunsStatus from './imports/RunStatus';
|
||||
import Share from '../shares/Share';
|
||||
import TriggersList from './TriggersList';
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
|
||||
|
||||
function getMenus(t) {
|
||||
return {
|
||||
|
@ -27,7 +28,7 @@ function getMenus(t) {
|
|||
panelComponent: ListsList,
|
||||
children: {
|
||||
':listId([0-9]+)': {
|
||||
title: resolved => t('listName', {name: resolved.list.name}),
|
||||
title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}),
|
||||
resolve: {
|
||||
list: params => `rest/lists/${params.listId}`
|
||||
},
|
||||
|
@ -78,7 +79,7 @@ function getMenus(t) {
|
|||
panelRender: props => <FieldsList list={props.resolved.list} />,
|
||||
children: {
|
||||
':fieldId([0-9]+)': {
|
||||
title: resolved => t('fieldName-1', {name: resolved.field.name}),
|
||||
title: resolved => t('fieldName-1', {name: ellipsizeBreadcrumbLabel(resolved.field.name)}),
|
||||
resolve: {
|
||||
field: params => `rest/fields/${params.listId}/${params.fieldId}`,
|
||||
fields: params => `rest/fields/${params.listId}`
|
||||
|
@ -108,7 +109,7 @@ function getMenus(t) {
|
|||
panelRender: props => <SegmentsList list={props.resolved.list} />,
|
||||
children: {
|
||||
':segmentId([0-9]+)': {
|
||||
title: resolved => t('segmentName', {name: resolved.segment.name}),
|
||||
title: resolved => t('segmentName', {name: ellipsizeBreadcrumbLabel(resolved.segment.name)}),
|
||||
resolve: {
|
||||
segment: params => `rest/segments/${params.listId}/${params.segmentId}`,
|
||||
fields: params => `rest/fields/${params.listId}`
|
||||
|
@ -138,7 +139,7 @@ function getMenus(t) {
|
|||
panelRender: props => <ImportsList list={props.resolved.list} />,
|
||||
children: {
|
||||
':importId([0-9]+)': {
|
||||
title: resolved => t('importName-1', {name: resolved.import.name}),
|
||||
title: resolved => t('importName-1', {name: ellipsizeBreadcrumbLabel(resolved.import.name)}),
|
||||
resolve: {
|
||||
import: params => `rest/imports/${params.listId}/${params.importId}`,
|
||||
},
|
||||
|
@ -198,7 +199,7 @@ function getMenus(t) {
|
|||
panelComponent: FormsList,
|
||||
children: {
|
||||
':formsId([0-9]+)': {
|
||||
title: resolved => t('customFormsName', {name: resolved.forms.name}),
|
||||
title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}),
|
||||
resolve: {
|
||||
forms: params => `rest/forms/${params.formsId}`
|
||||
},
|
||||
|
|
|
@ -119,16 +119,18 @@ export default class CUD extends Component {
|
|||
return tree;
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.rootRuleType = data.settings.rootRule.type;
|
||||
data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
|
||||
|
||||
this.setState({
|
||||
rulesTree: this.getTreeFromRules(data.settings.rootRule.rules)
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.setState({
|
||||
rulesTree: this.getTreeFromRules(this.props.entity.settings.rootRule.rules)
|
||||
});
|
||||
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.rootRuleType = data.settings.rootRule.type;
|
||||
data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
|
@ -159,7 +161,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async doSubmit(stay) {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -175,7 +177,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const keep = ['name', 'settings', 'originalHash'];
|
||||
|
||||
data.settings.rootRule.type = data.rootRuleType;
|
||||
|
@ -184,20 +186,22 @@ export default class CUD extends Component {
|
|||
delete data.selectedRule;
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
if (stay) {
|
||||
await this.getFormValuesFromURL(`rest/segments/${this.props.list.id}/${this.props.entity.id}`, data => {
|
||||
data.rootRuleType = data.settings.rootRule.type;
|
||||
data.selectedRule = null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('Segment updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/segments/${this.props.list.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
|
||||
this.setState({
|
||||
rulesTree: this.getTreeFromRules(data.settings.rootRule.rules)
|
||||
});
|
||||
});
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('segmentSaved'));
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Segment updated'));
|
||||
}
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('segmentSaved'));
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('Segment created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments/${submitResult}/edit`, 'success', t('Segment created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
@ -208,14 +212,6 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async submitAndStay() {
|
||||
await this.formHandleChangedError(async () => await this.doSubmit(true));
|
||||
}
|
||||
|
||||
async submitAndLeave() {
|
||||
await this.formHandleChangedError(async () => await this.doSubmit(false));
|
||||
}
|
||||
|
||||
onRulesChanged(rulesTree) {
|
||||
// This assumes that !this.state.ruleOptionsVisible
|
||||
this.getFormValue('settings').rootRule.rules = this.getRulesFromTree(rulesTree);
|
||||
|
@ -354,7 +350,7 @@ export default class CUD extends Component {
|
|||
|
||||
<Title>{isEdit ? t('editSegment') : t('createSegment')}</Title>
|
||||
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<h3>{t('segmentOptions')}</h3>
|
||||
|
||||
<InputField id="name" label={t('name')} />
|
||||
|
@ -407,19 +403,12 @@ export default class CUD extends Component {
|
|||
</div>
|
||||
|
||||
<hr/>
|
||||
{isEdit ?
|
||||
<ButtonRow format="wide" className={`col-xs-12 ${styles.toolbar}`}>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndStay')} onClickAsync={::this.submitAndStay}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')} onClickAsync={::this.submitAndLeave}/>
|
||||
|
||||
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/segments/${this.props.entity.id}/delete`}/>
|
||||
</ButtonRow>
|
||||
:
|
||||
<ButtonRow format="wide" className={`col-xs-12 ${styles.toolbar}`}>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')} onClickAsync={::this.submitAndLeave}/>
|
||||
</ButtonRow>
|
||||
}
|
||||
<ButtonRow format="wide" className={`col-12 ${styles.toolbar}`}>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')} onClickAsync={async () => this.submitHandler(false)}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
|
||||
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/segments/${this.props.entity.id}/delete`}/> }
|
||||
</ButtonRow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,152 +1,152 @@
|
|||
$desktopMinWidth: 768px;
|
||||
|
||||
$mobileLeftPaneResidualWidth: 0px;
|
||||
$mobileAnimationStartPosition: 100px;
|
||||
|
||||
$desktopLeftPaneResidualWidth: 200px;
|
||||
$desktopAnimationStartPosition: 300px;
|
||||
|
||||
@mixin optionsHidden {
|
||||
transform: translateX($mobileAnimationStartPosition);
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
transform: translateX($desktopAnimationStartPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin optionsVisible {
|
||||
transform: translateX($mobileLeftPaneResidualWidth);
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
transform: translateX($desktopLeftPaneResidualWidth);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ruleActionLink {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.rulePane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.leftPane {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-right: -100%;
|
||||
|
||||
.leftPaneInner {
|
||||
.ruleTree {
|
||||
background: #fbfbfb;
|
||||
border: #cfcfcf 1px solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px 0px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
// Without this, the placeholders when rearranging the tree are not shown
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.leftPaneOverlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
|
||||
width: $mobileLeftPaneResidualWidth;
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
width: $desktopLeftPaneResidualWidth;
|
||||
}
|
||||
}
|
||||
|
||||
.paneDivider {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url('./divider.png') repeat-y;
|
||||
|
||||
@include optionsHidden;
|
||||
|
||||
padding-left: 50px;
|
||||
z-index: 1;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.paneDividerSolidBackground {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightPane {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: top;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
|
||||
@include optionsHidden;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.rightPaneInner {
|
||||
margin-right: $mobileLeftPaneResidualWidth;
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
margin-right: $desktopLeftPaneResidualWidth;
|
||||
}
|
||||
|
||||
.ruleOptions {
|
||||
margin-left: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ruleOptionsVisible {
|
||||
.leftPaneOverlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.paneDivider {
|
||||
transition: transform 300ms ease-out, opacity 100ms ease-out;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
@include optionsVisible;
|
||||
}
|
||||
|
||||
.rightPane {
|
||||
transition: transform 300ms ease-out, opacity 100ms ease-out;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
@include optionsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
&.ruleOptionsHidden {
|
||||
.paneDivider {
|
||||
transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
|
||||
}
|
||||
|
||||
.rightPane {
|
||||
transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
$desktopMinWidth: 768px;
|
||||
|
||||
$mobileLeftPaneResidualWidth: 0px;
|
||||
$mobileAnimationStartPosition: 100px;
|
||||
|
||||
$desktopLeftPaneResidualWidth: 200px;
|
||||
$desktopAnimationStartPosition: 300px;
|
||||
|
||||
@mixin optionsHidden {
|
||||
transform: translateX($mobileAnimationStartPosition);
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
transform: translateX($desktopAnimationStartPosition);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin optionsVisible {
|
||||
transform: translateX($mobileLeftPaneResidualWidth);
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
transform: translateX($desktopLeftPaneResidualWidth);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ruleActionLink {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.rulePane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.leftPane {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-right: -100%;
|
||||
|
||||
.leftPaneInner {
|
||||
.ruleTree {
|
||||
background: #fbfbfb;
|
||||
border: #cfcfcf 1px solid;
|
||||
border-radius: 4px;
|
||||
padding: 10px 0px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
|
||||
// Without this, the placeholders when rearranging the tree are not shown
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.leftPaneOverlay {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
|
||||
width: $mobileLeftPaneResidualWidth;
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
width: $desktopLeftPaneResidualWidth;
|
||||
}
|
||||
}
|
||||
|
||||
.paneDivider {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url('./divider.png') repeat-y;
|
||||
|
||||
@include optionsHidden;
|
||||
|
||||
padding-left: 50px;
|
||||
z-index: 1;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.paneDividerSolidBackground {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rightPane {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
vertical-align: top;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
|
||||
@include optionsHidden;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
.rightPaneInner {
|
||||
margin-right: $mobileLeftPaneResidualWidth;
|
||||
@media (min-width: $desktopMinWidth) {
|
||||
margin-right: $desktopLeftPaneResidualWidth;
|
||||
}
|
||||
|
||||
.ruleOptions {
|
||||
margin-left: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.ruleOptionsVisible {
|
||||
.leftPaneOverlay {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.paneDivider {
|
||||
transition: transform 300ms ease-out, opacity 100ms ease-out;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
@include optionsVisible;
|
||||
}
|
||||
|
||||
.rightPane {
|
||||
transition: transform 300ms ease-out, opacity 100ms ease-out;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
|
||||
@include optionsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
&.ruleOptionsHidden {
|
||||
.paneDivider {
|
||||
transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
|
||||
}
|
||||
|
||||
.rightPane {
|
||||
transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,459 +1,459 @@
|
|||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import {DatePicker, Dropdown, InputField} from "../../lib/form";
|
||||
import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../../shared/date';
|
||||
import { tMark } from "../../lib/i18n";
|
||||
|
||||
export function getRuleHelpers(t, fields) {
|
||||
|
||||
const ruleHelpers = {};
|
||||
|
||||
ruleHelpers.compositeRuleTypes = {
|
||||
all: {
|
||||
dropdownLabel: t('allRulesMustMatch'),
|
||||
treeLabel: rule => t('allRulesMustMatch')
|
||||
},
|
||||
some: {
|
||||
dropdownLabel: t('atLeastOneRuleMustMatch'),
|
||||
treeLabel: rule => t('atLeastOneRuleMustMatch')
|
||||
},
|
||||
none: {
|
||||
dropdownLabel: t('noRuleMayMatch'),
|
||||
treeLabel: rule => t('noRuleMayMatch')
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes = {};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.text = {
|
||||
eq: {
|
||||
dropdownLabel: t('equalTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
like: {
|
||||
dropdownLabel: t('matchWithSqlLike'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithSqlLike', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
re: {
|
||||
dropdownLabel: t('matchWithRegularExpressions'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithRegular', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('alphabeticallyBefore'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('alphabeticallyBeforeOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('alphabeticallyAfter'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-2', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('alphabeticallyAfterOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-3', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.website = {
|
||||
eq: {
|
||||
dropdownLabel: t('equalTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
like: {
|
||||
dropdownLabel: t('matchWithSqlLike'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithSqlLike', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
re: {
|
||||
dropdownLabel: t('matchWithRegularExpressions'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithRegular', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.number = {
|
||||
eq: {
|
||||
dropdownLabel: t('equalTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsEqualToValue-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('lessThan'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsLessThanValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('lessThanOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsLessThanOrEqualTo', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('greaterThan'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsGreaterThanValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('greaterThanOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsGreaterThanOrEqual', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
// FXIME - the localization here is still wrong
|
||||
function getRelativeDateTreeLabel(rule, variants) {
|
||||
if (rule.value === 0) {
|
||||
return t(variants[0], {colName: ruleHelpers.getColumnName(rule.column)})
|
||||
} else if (rule.value > 0) {
|
||||
return t(variants[1], {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
|
||||
} else {
|
||||
return t(variants[2], {colName: ruleHelpers.getColumnName(rule.column), value: -rule.value});
|
||||
}
|
||||
}
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.date = {
|
||||
eq: {
|
||||
dropdownLabel: t('on'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('before'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('beforeOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('after'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('afterOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
eqTodayPlusDays: {
|
||||
dropdownLabel: t('onXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsTheCurrentDate'), tMark('dateInColumnColNameIsTheValuethDayAfter'), tMark('dateInColumnColNameIsTheValuethDayBefore')]),
|
||||
},
|
||||
ltTodayPlusDays: {
|
||||
dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsBeforeTheCurrent'), tMark('dateInColumnColNameIsBeforeTheValuethDay'), tMark('dateInColumnColNameIsBeforeTheValuethDay-1')]),
|
||||
},
|
||||
leTodayPlusDays: {
|
||||
dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsBeforeOrOnThe'), tMark('dateInColumnColNameIsBeforeOrOnThe-1'), tMark('dateInColumnColNameIsBeforeOrOnThe-2')]),
|
||||
},
|
||||
gtTodayPlusDays: {
|
||||
dropdownLabel: t('afterXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsAfterTheCurrentDate'), tMark('dateInColumnColNameIsAfterTheValuethDay'), tMark('dateInColumnColNameIsAfterTheValuethDay-1')]),
|
||||
},
|
||||
geTodayPlusDays: {
|
||||
dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsAfterOrOnTheCurrent'), tMark('dateInColumnColNameIsAfterOrOnTheValueth'), tMark('dateInColumnColNameIsAfterOrOnTheValueth-1')]),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.birthday = {
|
||||
eq: {
|
||||
dropdownLabel: t('on'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('before'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('beforeOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('after'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('afterOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.option = {
|
||||
isTrue: {
|
||||
dropdownLabel: t('isSelected'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsSelected', {colName: ruleHelpers.getColumnName(rule.column)}),
|
||||
},
|
||||
isFalse: {
|
||||
dropdownLabel: t('isNotSelected'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsNotSelected', {colName: ruleHelpers.getColumnName(rule.column)}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes['dropdown-enum'] = ruleHelpers.primitiveRuleTypes['radio-enum'] = {
|
||||
eq: {
|
||||
dropdownLabel: t('keyEqualTo'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIsEqualTo', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
like: {
|
||||
dropdownLabel: t('keyMatchWithSqlLike'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameMatchesWith', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
re: {
|
||||
dropdownLabel: t('keyMatchWithRegularExpressions'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameMatchesWith-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('keyAlphabeticallyBefore'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('keyAlphabeticallyBeforeOrEqualTo'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('keyAlphabeticallyAfter'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-2', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('keyAlphabeticallyAfterOrEqualTo'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-3', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const stringValueSettings = allowEmpty => ({
|
||||
getForm: () => <InputField id="value" label={t('value')} />,
|
||||
getFormData: rule => ({
|
||||
value: rule.value
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = getter('value');
|
||||
},
|
||||
validate: state => {
|
||||
if (!allowEmpty && !state.getIn(['value', 'value'])) {
|
||||
state.setIn(['value', 'error'], t('valueMustNotBeEmpty'));
|
||||
} else {
|
||||
state.setIn(['value', 'error'], null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const numberValueSettings = {
|
||||
getForm: () => <InputField id="value" label={t('value')} />,
|
||||
getFormData: rule => ({
|
||||
value: rule.value.toString()
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = parseInt(getter('value'));
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['value', 'value']).trim();
|
||||
if (value === '') {
|
||||
state.setIn(['value', 'error'], t('valueMustNotBeEmpty'));
|
||||
} else if (isNaN(value)) {
|
||||
state.setIn(['value', 'error'], t('valueMustBeANumber'));
|
||||
} else {
|
||||
state.setIn(['value', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const birthdayValueSettings = {
|
||||
getForm: () => <DatePicker id="birthday" label={t('date')} birthday />,
|
||||
getFormData: rule => ({
|
||||
birthday: formatBirthday(DateFormat.INTL, rule.value)
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = parseBirthday(DateFormat.INTL, getter('birthday')).toISOString();
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['birthday', 'value']);
|
||||
const date = parseBirthday(DateFormat.INTL, value);
|
||||
if (!value) {
|
||||
state.setIn(['birthday', 'error'], t('dateMustNotBeEmpty'));
|
||||
} else if (!date) {
|
||||
state.setIn(['birthday', 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn(['birthday', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dateValueSettings = {
|
||||
getForm: () => <DatePicker id="date" label={t('date')} />,
|
||||
getFormData: rule => ({
|
||||
date: formatDate(DateFormat.INTL, rule.value)
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = parseDate(DateFormat.INTL, getter('date')).toISOString();
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['date', 'value']);
|
||||
const date = parseDate(DateFormat.INTL, value);
|
||||
if (!value) {
|
||||
state.setIn(['date', 'error'], t('dateMustNotBeEmpty'));
|
||||
} else if (!date) {
|
||||
state.setIn(['date', 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn(['date', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dateRelativeValueSettings = {
|
||||
getForm: () =>
|
||||
<div>
|
||||
<InputField id="daysValue" label={t('numberOfDays')}/>
|
||||
<Dropdown id="direction" label={t('beforeAfter')} options={[
|
||||
{ key: 'before', label: t('beforeCurrentDate') },
|
||||
{ key: 'after', label: t('afterCurrentDate') }
|
||||
]}/>
|
||||
</div>,
|
||||
getFormData: rule => ({
|
||||
daysValue: Math.abs(rule.value).toString(),
|
||||
direction: rule.value >= 0 ? 'after' : 'before'
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
const direction = getter('direction');
|
||||
rule.value = parseInt(getter('daysValue')) * (direction === 'before' ? -1 : 1);
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['daysValue', 'value']);
|
||||
if (!value) {
|
||||
state.setIn(['daysValue', 'error'], t('numberOfDaysMustNotBeEmpty'));
|
||||
} else if (isNaN(value)) {
|
||||
state.setIn(['daysValue', 'error'], t('numberOfDaysMustBeANumber'));
|
||||
} else {
|
||||
state.setIn(['daysValue', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const optionValueSettings = {
|
||||
getForm: () => null,
|
||||
getFormData: rule => ({}),
|
||||
assignRuleSettings: (rule, getter) => {},
|
||||
validate: state => {}
|
||||
};
|
||||
|
||||
|
||||
function assignSettingsToRuleTypes(ruleTypes, keys, settings) {
|
||||
for (const key of keys) {
|
||||
Object.assign(ruleTypes[key], settings);
|
||||
}
|
||||
}
|
||||
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.website, ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.number, ['eq', 'lt', 'le', 'gt', 'ge'], numberValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.birthday, ['eq', 'lt', 'le', 'gt', 'ge'], birthdayValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eq', 'lt', 'le', 'gt', 'ge'], dateValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'], dateRelativeValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.option, ['isTrue', 'isFalse'], optionValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
|
||||
|
||||
ruleHelpers.primitiveRuleTypesFormDataDefaults = {
|
||||
value: '',
|
||||
date: '',
|
||||
daysValue: '',
|
||||
birthday: '',
|
||||
direction: 'before'
|
||||
};
|
||||
|
||||
|
||||
|
||||
ruleHelpers.getCompositeRuleTypeOptions = () => {
|
||||
const order = ['all', 'some', 'none'];
|
||||
return order.map(key => ({ key, label: ruleHelpers.compositeRuleTypes[key].dropdownLabel }));
|
||||
};
|
||||
|
||||
ruleHelpers.getPrimitiveRuleTypeOptions = columnType => {
|
||||
const order = {
|
||||
text: ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'],
|
||||
website: ['eq', 'like', 're'],
|
||||
number: ['eq', 'lt', 'le', 'gt', 'ge'],
|
||||
birthday: ['eq', 'lt', 'le', 'gt', 'ge'],
|
||||
date: ['eq', 'lt', 'le', 'gt', 'ge', 'eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'],
|
||||
option: ['isTrue', 'isFalse'],
|
||||
'dropdown-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'],
|
||||
'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge']
|
||||
};
|
||||
|
||||
return order[columnType].map(key => ({ key, label: ruleHelpers.primitiveRuleTypes[columnType][key].dropdownLabel }));
|
||||
};
|
||||
|
||||
const predefColumns = [
|
||||
{
|
||||
column: 'email',
|
||||
name: t('emailAddress-1'),
|
||||
type: 'text',
|
||||
key: 'EMAIL'
|
||||
},
|
||||
{
|
||||
column: 'opt_in_country',
|
||||
name: t('signupCountry'),
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
column: 'created',
|
||||
name: t('signUpDate'),
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
column: 'latest_open',
|
||||
name: t('latestOpen'),
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
column: 'latest_click',
|
||||
name: t('latestClick'),
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
column: 'is_test',
|
||||
name: t('Test user'),
|
||||
type: 'option'
|
||||
}
|
||||
];
|
||||
|
||||
ruleHelpers.fields = [
|
||||
...predefColumns,
|
||||
...fields.filter(fld => fld.type in ruleHelpers.primitiveRuleTypes)
|
||||
];
|
||||
|
||||
ruleHelpers.fieldsByColumn = {};
|
||||
for (const fld of ruleHelpers.fields) {
|
||||
ruleHelpers.fieldsByColumn[fld.column] = fld;
|
||||
}
|
||||
|
||||
ruleHelpers.getColumnType = column => {
|
||||
const field = ruleHelpers.fieldsByColumn[column];
|
||||
if (field) {
|
||||
return field.type;
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.getColumnName = column => {
|
||||
const field = ruleHelpers.fieldsByColumn[column];
|
||||
if (field) {
|
||||
return field.name;
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.getRuleTypeSettings = rule => {
|
||||
if (ruleHelpers.isCompositeRuleType(rule.type)) {
|
||||
return ruleHelpers.compositeRuleTypes[rule.type];
|
||||
} else {
|
||||
const colType = ruleHelpers.getColumnType(rule.column);
|
||||
|
||||
if (colType) {
|
||||
if (rule.type in ruleHelpers.primitiveRuleTypes[colType]) {
|
||||
return ruleHelpers.primitiveRuleTypes[colType][rule.type];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.isCompositeRuleType = ruleType => ruleType in ruleHelpers.compositeRuleTypes;
|
||||
|
||||
return ruleHelpers;
|
||||
}
|
||||
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import {DatePicker, Dropdown, InputField} from "../../lib/form";
|
||||
import { parseDate, parseBirthday, formatDate, formatBirthday, DateFormat, birthdayYear, getDateFormatString, getBirthdayFormatString } from '../../../../shared/date';
|
||||
import { tMark } from "../../lib/i18n";
|
||||
|
||||
export function getRuleHelpers(t, fields) {
|
||||
|
||||
const ruleHelpers = {};
|
||||
|
||||
ruleHelpers.compositeRuleTypes = {
|
||||
all: {
|
||||
dropdownLabel: t('allRulesMustMatch'),
|
||||
treeLabel: rule => t('allRulesMustMatch')
|
||||
},
|
||||
some: {
|
||||
dropdownLabel: t('atLeastOneRuleMustMatch'),
|
||||
treeLabel: rule => t('atLeastOneRuleMustMatch')
|
||||
},
|
||||
none: {
|
||||
dropdownLabel: t('noRuleMayMatch'),
|
||||
treeLabel: rule => t('noRuleMayMatch')
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes = {};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.text = {
|
||||
eq: {
|
||||
dropdownLabel: t('equalTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
like: {
|
||||
dropdownLabel: t('matchWithSqlLike'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithSqlLike', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
re: {
|
||||
dropdownLabel: t('matchWithRegularExpressions'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithRegular', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('alphabeticallyBefore'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('alphabeticallyBeforeOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('alphabeticallyAfter'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-2', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('alphabeticallyAfterOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsAlphabetically-3', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.website = {
|
||||
eq: {
|
||||
dropdownLabel: t('equalTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsEqualToValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
like: {
|
||||
dropdownLabel: t('matchWithSqlLike'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithSqlLike', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
re: {
|
||||
dropdownLabel: t('matchWithRegularExpressions'),
|
||||
treeLabel: rule => t('valueInColumnColNameMatchesWithRegular', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.number = {
|
||||
eq: {
|
||||
dropdownLabel: t('equalTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsEqualToValue-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('lessThan'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsLessThanValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('lessThanOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsLessThanOrEqualTo', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('greaterThan'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsGreaterThanValue', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('greaterThanOrEqualTo'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsGreaterThanOrEqual', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
// FXIME - the localization here is still wrong
|
||||
function getRelativeDateTreeLabel(rule, variants) {
|
||||
if (rule.value === 0) {
|
||||
return t(variants[0], {colName: ruleHelpers.getColumnName(rule.column)})
|
||||
} else if (rule.value > 0) {
|
||||
return t(variants[1], {colName: ruleHelpers.getColumnName(rule.column), value: rule.value});
|
||||
} else {
|
||||
return t(variants[2], {colName: ruleHelpers.getColumnName(rule.column), value: -rule.value});
|
||||
}
|
||||
}
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.date = {
|
||||
eq: {
|
||||
dropdownLabel: t('on'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('before'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('beforeOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('after'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('afterOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatDate(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
eqTodayPlusDays: {
|
||||
dropdownLabel: t('onXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsTheCurrentDate'), tMark('dateInColumnColNameIsTheValuethDayAfter'), tMark('dateInColumnColNameIsTheValuethDayBefore')]),
|
||||
},
|
||||
ltTodayPlusDays: {
|
||||
dropdownLabel: t('beforeXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsBeforeTheCurrent'), tMark('dateInColumnColNameIsBeforeTheValuethDay'), tMark('dateInColumnColNameIsBeforeTheValuethDay-1')]),
|
||||
},
|
||||
leTodayPlusDays: {
|
||||
dropdownLabel: t('beforeOrOnXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsBeforeOrOnThe'), tMark('dateInColumnColNameIsBeforeOrOnThe-1'), tMark('dateInColumnColNameIsBeforeOrOnThe-2')]),
|
||||
},
|
||||
gtTodayPlusDays: {
|
||||
dropdownLabel: t('afterXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsAfterTheCurrentDate'), tMark('dateInColumnColNameIsAfterTheValuethDay'), tMark('dateInColumnColNameIsAfterTheValuethDay-1')]),
|
||||
},
|
||||
geTodayPlusDays: {
|
||||
dropdownLabel: t('afterOrOnXthDayBeforeafterCurrentDate'),
|
||||
treeLabel: rule => getRelativeDateTreeLabel(rule, [tMark('dateInColumnColNameIsAfterOrOnTheCurrent'), tMark('dateInColumnColNameIsAfterOrOnTheValueth'), tMark('dateInColumnColNameIsAfterOrOnTheValueth-1')]),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.birthday = {
|
||||
eq: {
|
||||
dropdownLabel: t('on'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('before'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('beforeOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsBeforeOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('after'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('afterOrOn'),
|
||||
treeLabel: rule => t('dateInColumnColNameIsAfterOrOnValue', {colName: ruleHelpers.getColumnName(rule.column), value: formatBirthday(DateFormat.INTL, rule.value)}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes.option = {
|
||||
isTrue: {
|
||||
dropdownLabel: t('isSelected'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsSelected', {colName: ruleHelpers.getColumnName(rule.column)}),
|
||||
},
|
||||
isFalse: {
|
||||
dropdownLabel: t('isNotSelected'),
|
||||
treeLabel: rule => t('valueInColumnColNameIsNotSelected', {colName: ruleHelpers.getColumnName(rule.column)}),
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.primitiveRuleTypes['dropdown-enum'] = ruleHelpers.primitiveRuleTypes['radio-enum'] = {
|
||||
eq: {
|
||||
dropdownLabel: t('keyEqualTo'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIsEqualTo', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
like: {
|
||||
dropdownLabel: t('keyMatchWithSqlLike'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameMatchesWith', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
re: {
|
||||
dropdownLabel: t('keyMatchWithRegularExpressions'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameMatchesWith-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
lt: {
|
||||
dropdownLabel: t('keyAlphabeticallyBefore'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
le: {
|
||||
dropdownLabel: t('keyAlphabeticallyBeforeOrEqualTo'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-1', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
gt: {
|
||||
dropdownLabel: t('keyAlphabeticallyAfter'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-2', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
},
|
||||
ge: {
|
||||
dropdownLabel: t('keyAlphabeticallyAfterOrEqualTo'),
|
||||
treeLabel: rule => t('theSelectedKeyInColumnColNameIs-3', {colName: ruleHelpers.getColumnName(rule.column), value: rule.value}),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const stringValueSettings = allowEmpty => ({
|
||||
getForm: () => <InputField id="value" label={t('value')} />,
|
||||
getFormData: rule => ({
|
||||
value: rule.value
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = getter('value');
|
||||
},
|
||||
validate: state => {
|
||||
if (!allowEmpty && !state.getIn(['value', 'value'])) {
|
||||
state.setIn(['value', 'error'], t('valueMustNotBeEmpty'));
|
||||
} else {
|
||||
state.setIn(['value', 'error'], null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const numberValueSettings = {
|
||||
getForm: () => <InputField id="value" label={t('value')} />,
|
||||
getFormData: rule => ({
|
||||
value: rule.value.toString()
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = parseInt(getter('value'));
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['value', 'value']).trim();
|
||||
if (value === '') {
|
||||
state.setIn(['value', 'error'], t('valueMustNotBeEmpty'));
|
||||
} else if (isNaN(value)) {
|
||||
state.setIn(['value', 'error'], t('valueMustBeANumber'));
|
||||
} else {
|
||||
state.setIn(['value', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const birthdayValueSettings = {
|
||||
getForm: () => <DatePicker id="birthday" label={t('date')} birthday />,
|
||||
getFormData: rule => ({
|
||||
birthday: formatBirthday(DateFormat.INTL, rule.value)
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = parseBirthday(DateFormat.INTL, getter('birthday')).toISOString();
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['birthday', 'value']);
|
||||
const date = parseBirthday(DateFormat.INTL, value);
|
||||
if (!value) {
|
||||
state.setIn(['birthday', 'error'], t('dateMustNotBeEmpty'));
|
||||
} else if (!date) {
|
||||
state.setIn(['birthday', 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn(['birthday', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dateValueSettings = {
|
||||
getForm: () => <DatePicker id="date" label={t('date')} />,
|
||||
getFormData: rule => ({
|
||||
date: formatDate(DateFormat.INTL, rule.value)
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
rule.value = parseDate(DateFormat.INTL, getter('date')).toISOString();
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['date', 'value']);
|
||||
const date = parseDate(DateFormat.INTL, value);
|
||||
if (!value) {
|
||||
state.setIn(['date', 'error'], t('dateMustNotBeEmpty'));
|
||||
} else if (!date) {
|
||||
state.setIn(['date', 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn(['date', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dateRelativeValueSettings = {
|
||||
getForm: () =>
|
||||
<div>
|
||||
<InputField id="daysValue" label={t('numberOfDays')}/>
|
||||
<Dropdown id="direction" label={t('beforeAfter')} options={[
|
||||
{ key: 'before', label: t('beforeCurrentDate') },
|
||||
{ key: 'after', label: t('afterCurrentDate') }
|
||||
]}/>
|
||||
</div>,
|
||||
getFormData: rule => ({
|
||||
daysValue: Math.abs(rule.value).toString(),
|
||||
direction: rule.value >= 0 ? 'after' : 'before'
|
||||
}),
|
||||
assignRuleSettings: (rule, getter) => {
|
||||
const direction = getter('direction');
|
||||
rule.value = parseInt(getter('daysValue')) * (direction === 'before' ? -1 : 1);
|
||||
},
|
||||
validate: state => {
|
||||
const value = state.getIn(['daysValue', 'value']);
|
||||
if (!value) {
|
||||
state.setIn(['daysValue', 'error'], t('numberOfDaysMustNotBeEmpty'));
|
||||
} else if (isNaN(value)) {
|
||||
state.setIn(['daysValue', 'error'], t('numberOfDaysMustBeANumber'));
|
||||
} else {
|
||||
state.setIn(['daysValue', 'error'], null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const optionValueSettings = {
|
||||
getForm: () => null,
|
||||
getFormData: rule => ({}),
|
||||
assignRuleSettings: (rule, getter) => {},
|
||||
validate: state => {}
|
||||
};
|
||||
|
||||
|
||||
function assignSettingsToRuleTypes(ruleTypes, keys, settings) {
|
||||
for (const key of keys) {
|
||||
Object.assign(ruleTypes[key], settings);
|
||||
}
|
||||
}
|
||||
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.text, ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.website, ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.number, ['eq', 'lt', 'le', 'gt', 'ge'], numberValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.birthday, ['eq', 'lt', 'le', 'gt', 'ge'], birthdayValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eq', 'lt', 'le', 'gt', 'ge'], dateValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.date, ['eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'], dateRelativeValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes.option, ['isTrue', 'isFalse'], optionValueSettings);
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['dropdown-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['eq', 'like', 're'], stringValueSettings(true));
|
||||
assignSettingsToRuleTypes(ruleHelpers.primitiveRuleTypes['radio-enum'], ['lt', 'le', 'gt', 'ge'], stringValueSettings(false));
|
||||
|
||||
ruleHelpers.primitiveRuleTypesFormDataDefaults = {
|
||||
value: '',
|
||||
date: '',
|
||||
daysValue: '',
|
||||
birthday: '',
|
||||
direction: 'before'
|
||||
};
|
||||
|
||||
|
||||
|
||||
ruleHelpers.getCompositeRuleTypeOptions = () => {
|
||||
const order = ['all', 'some', 'none'];
|
||||
return order.map(key => ({ key, label: ruleHelpers.compositeRuleTypes[key].dropdownLabel }));
|
||||
};
|
||||
|
||||
ruleHelpers.getPrimitiveRuleTypeOptions = columnType => {
|
||||
const order = {
|
||||
text: ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'],
|
||||
website: ['eq', 'like', 're'],
|
||||
number: ['eq', 'lt', 'le', 'gt', 'ge'],
|
||||
birthday: ['eq', 'lt', 'le', 'gt', 'ge'],
|
||||
date: ['eq', 'lt', 'le', 'gt', 'ge', 'eqTodayPlusDays', 'ltTodayPlusDays', 'leTodayPlusDays', 'gtTodayPlusDays', 'geTodayPlusDays'],
|
||||
option: ['isTrue', 'isFalse'],
|
||||
'dropdown-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge'],
|
||||
'radio-enum': ['eq', 'like', 're', 'lt', 'le', 'gt', 'ge']
|
||||
};
|
||||
|
||||
return order[columnType].map(key => ({ key, label: ruleHelpers.primitiveRuleTypes[columnType][key].dropdownLabel }));
|
||||
};
|
||||
|
||||
const predefColumns = [
|
||||
{
|
||||
column: 'email',
|
||||
name: t('emailAddress-1'),
|
||||
type: 'text',
|
||||
key: 'EMAIL'
|
||||
},
|
||||
{
|
||||
column: 'opt_in_country',
|
||||
name: t('signupCountry'),
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
column: 'created',
|
||||
name: t('signUpDate'),
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
column: 'latest_open',
|
||||
name: t('latestOpen'),
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
column: 'latest_click',
|
||||
name: t('latestClick'),
|
||||
type: 'date'
|
||||
},
|
||||
{
|
||||
column: 'is_test',
|
||||
name: t('Test user'),
|
||||
type: 'option'
|
||||
}
|
||||
];
|
||||
|
||||
ruleHelpers.fields = [
|
||||
...predefColumns,
|
||||
...fields.filter(fld => fld.type in ruleHelpers.primitiveRuleTypes)
|
||||
];
|
||||
|
||||
ruleHelpers.fieldsByColumn = {};
|
||||
for (const fld of ruleHelpers.fields) {
|
||||
ruleHelpers.fieldsByColumn[fld.column] = fld;
|
||||
}
|
||||
|
||||
ruleHelpers.getColumnType = column => {
|
||||
const field = ruleHelpers.fieldsByColumn[column];
|
||||
if (field) {
|
||||
return field.type;
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.getColumnName = column => {
|
||||
const field = ruleHelpers.fieldsByColumn[column];
|
||||
if (field) {
|
||||
return field.name;
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.getRuleTypeSettings = rule => {
|
||||
if (ruleHelpers.isCompositeRuleType(rule.type)) {
|
||||
return ruleHelpers.compositeRuleTypes[rule.type];
|
||||
} else {
|
||||
const colType = ruleHelpers.getColumnType(rule.column);
|
||||
|
||||
if (colType) {
|
||||
if (rule.type in ruleHelpers.primitiveRuleTypes[colType]) {
|
||||
return ruleHelpers.primitiveRuleTypes[colType][rule.type];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ruleHelpers.isCompositeRuleType = ruleType => ruleType in ruleHelpers.compositeRuleTypes;
|
||||
|
||||
return ruleHelpers;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
.mapping {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.erased {
|
||||
color: #808080;
|
||||
.mapping {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.erased {
|
||||
color: #808080;
|
||||
}
|
|
@ -69,16 +69,18 @@ export default class CUD extends Component {
|
|||
entity: PropTypes.object
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.status = data.status.toString();
|
||||
data.tz = data.tz || '';
|
||||
|
||||
for (const fld of this.props.fieldsGrouped) {
|
||||
this.fieldTypes[fld.type].assignFormData(fld, data);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.status = data.status.toString();
|
||||
data.tz = data.tz || '';
|
||||
|
||||
for (const fld of this.props.fieldsGrouped) {
|
||||
this.fieldTypes[fld.type].assignFormData(fld, data);
|
||||
}
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
const data = {
|
||||
|
@ -115,7 +117,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -131,7 +133,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
data.status = parseInt(data.status);
|
||||
data.tz = data.tz || null;
|
||||
|
||||
|
@ -140,8 +142,22 @@ export default class CUD extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('susbscriptionSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('Subscription updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/subscriptions/${this.props.list.id}/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Subscription updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions`, 'success', t('Subscription created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/subscriptions/${submitResult}/edit`, 'success', t('Subscription created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -222,7 +238,8 @@ export default class CUD extends Component {
|
|||
</AlignedRow>
|
||||
}
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{isEdit && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/lists/${this.props.list.id}/subscriptions/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -1,212 +1,212 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import {SubscriptionStatus} from "../../../../shared/lists";
|
||||
import {
|
||||
ACEEditor,
|
||||
CheckBox,
|
||||
CheckBoxGroup,
|
||||
DatePicker,
|
||||
Dropdown,
|
||||
InputField,
|
||||
RadioGroup,
|
||||
TextArea
|
||||
} from "../../lib/form";
|
||||
import {formatBirthday, formatDate, parseBirthday, parseDate} from "../../../../shared/date";
|
||||
import {getFieldColumn} from '../../../../shared/lists';
|
||||
import 'brace/mode/json';
|
||||
|
||||
export function getSubscriptionStatusLabels(t) {
|
||||
|
||||
const subscriptionStatusLabels = {
|
||||
[SubscriptionStatus.SUBSCRIBED]: t('subscribed'),
|
||||
[SubscriptionStatus.UNSUBSCRIBED]: t('unubscribed'),
|
||||
[SubscriptionStatus.BOUNCED]: t('bounced'),
|
||||
[SubscriptionStatus.COMPLAINED]: t('complained'),
|
||||
};
|
||||
|
||||
return subscriptionStatusLabels;
|
||||
}
|
||||
|
||||
export function getFieldTypes(t) {
|
||||
|
||||
const groupedFieldTypes = {};
|
||||
|
||||
const stringFieldType = long => ({
|
||||
form: groupedField => long ? <TextArea key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/> : <InputField key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value || '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: true
|
||||
});
|
||||
|
||||
const numberFieldType = {
|
||||
form: groupedField => <InputField key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value ? value.toString() : '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = parseInt(data[getFieldColumn(groupedField)]);
|
||||
},
|
||||
validate: (groupedField, state) => {
|
||||
const value = state.getIn([getFieldColumn(groupedField), 'value']).trim();
|
||||
if (value !== '' && isNaN(value)) {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], t('valueMustBeANumber'));
|
||||
} else {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], null);
|
||||
}
|
||||
},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const dateFieldType = {
|
||||
form: groupedField => <DatePicker key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} />,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value ? formatDate(groupedField.settings.dateFormat, value) : '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {
|
||||
const date = parseDate(groupedField.settings.dateFormat, data[getFieldColumn(groupedField)]);
|
||||
data[getFieldColumn(groupedField)] = date;
|
||||
},
|
||||
validate: (groupedField, state) => {
|
||||
const value = state.getIn([getFieldColumn(groupedField), 'value']);
|
||||
const date = parseDate(groupedField.settings.dateFormat, value);
|
||||
if (value !== '' && !date) {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], null);
|
||||
}
|
||||
},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const birthdayFieldType = {
|
||||
form: groupedField => <DatePicker key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} birthday />,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value ? formatBirthday(groupedField.settings.dateFormat, value) : '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {
|
||||
const date = parseBirthday(groupedField.settings.dateFormat, data[getFieldColumn(groupedField)]);
|
||||
data[getFieldColumn(groupedField)] = date;
|
||||
},
|
||||
validate: (groupedField, state) => {
|
||||
const value = state.getIn([getFieldColumn(groupedField), 'value']);
|
||||
const date = parseBirthday(groupedField.settings.dateFormat, value);
|
||||
if (value !== '' && !date) {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], null);
|
||||
}
|
||||
},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const jsonFieldType = {
|
||||
form: groupedField => <ACEEditor key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} mode="json" height="300px"/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value || '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: false
|
||||
};
|
||||
|
||||
const optionFieldType = {
|
||||
form: groupedField => <CheckBox key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} text={groupedField.settings.checkedLabel} label={groupedField.name}/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = !!value;
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = false;
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const enumSingleFieldType = componentType => ({
|
||||
form: groupedField => React.createElement(componentType, { key: getFieldColumn(groupedField), id: getFieldColumn(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
|
||||
assignFormData: (groupedField, data) => {
|
||||
if (data[getFieldColumn(groupedField)] === null) {
|
||||
if (groupedField.default_value) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.default_value;
|
||||
} else if (groupedField.settings.options.length > 0) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.settings.options[0].key;
|
||||
} else {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
if (groupedField.default_value) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.default_value;
|
||||
} else if (groupedField.settings.options.length > 0) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.settings.options[0].key;
|
||||
} else {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
}
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: false
|
||||
});
|
||||
|
||||
const enumMultipleFieldType = componentType => ({
|
||||
form: groupedField => React.createElement(componentType, { key: getFieldColumn(groupedField), id: getFieldColumn(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
|
||||
assignFormData: (groupedField, data) => {
|
||||
if (data[getFieldColumn(groupedField)] === null) {
|
||||
data[getFieldColumn(groupedField)] = [];
|
||||
}
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = [];
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: false
|
||||
});
|
||||
|
||||
|
||||
groupedFieldTypes.text = stringFieldType(false);
|
||||
groupedFieldTypes.website = stringFieldType(false);
|
||||
groupedFieldTypes.longtext = stringFieldType(true);
|
||||
groupedFieldTypes.gpg = stringFieldType(true);
|
||||
groupedFieldTypes.number = numberFieldType;
|
||||
groupedFieldTypes.date = dateFieldType;
|
||||
groupedFieldTypes.birthday = birthdayFieldType;
|
||||
groupedFieldTypes.json = jsonFieldType;
|
||||
groupedFieldTypes.option = optionFieldType;
|
||||
groupedFieldTypes['dropdown-enum'] = enumSingleFieldType(Dropdown);
|
||||
groupedFieldTypes['radio-enum'] = enumSingleFieldType(RadioGroup);
|
||||
|
||||
// Here we rely on the fact the model/groupedFields and model/subscriptions preprocess the groupedField info and subscription
|
||||
// such that the grouped entries behave the same as the enum entries
|
||||
groupedFieldTypes['checkbox-grouped'] = enumMultipleFieldType(CheckBoxGroup);
|
||||
groupedFieldTypes['radio-grouped'] = enumSingleFieldType(RadioGroup);
|
||||
groupedFieldTypes['dropdown-grouped'] = enumSingleFieldType(Dropdown);
|
||||
|
||||
return groupedFieldTypes;
|
||||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import {SubscriptionStatus} from "../../../../shared/lists";
|
||||
import {
|
||||
ACEEditor,
|
||||
CheckBox,
|
||||
CheckBoxGroup,
|
||||
DatePicker,
|
||||
Dropdown,
|
||||
InputField,
|
||||
RadioGroup,
|
||||
TextArea
|
||||
} from "../../lib/form";
|
||||
import {formatBirthday, formatDate, parseBirthday, parseDate} from "../../../../shared/date";
|
||||
import {getFieldColumn} from '../../../../shared/lists';
|
||||
import 'brace/mode/json';
|
||||
|
||||
export function getSubscriptionStatusLabels(t) {
|
||||
|
||||
const subscriptionStatusLabels = {
|
||||
[SubscriptionStatus.SUBSCRIBED]: t('subscribed'),
|
||||
[SubscriptionStatus.UNSUBSCRIBED]: t('unubscribed'),
|
||||
[SubscriptionStatus.BOUNCED]: t('bounced'),
|
||||
[SubscriptionStatus.COMPLAINED]: t('complained'),
|
||||
};
|
||||
|
||||
return subscriptionStatusLabels;
|
||||
}
|
||||
|
||||
export function getFieldTypes(t) {
|
||||
|
||||
const groupedFieldTypes = {};
|
||||
|
||||
const stringFieldType = long => ({
|
||||
form: groupedField => long ? <TextArea key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/> : <InputField key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value || '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: true
|
||||
});
|
||||
|
||||
const numberFieldType = {
|
||||
form: groupedField => <InputField key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name}/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value ? value.toString() : '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = parseInt(data[getFieldColumn(groupedField)]);
|
||||
},
|
||||
validate: (groupedField, state) => {
|
||||
const value = state.getIn([getFieldColumn(groupedField), 'value']).trim();
|
||||
if (value !== '' && isNaN(value)) {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], t('valueMustBeANumber'));
|
||||
} else {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], null);
|
||||
}
|
||||
},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const dateFieldType = {
|
||||
form: groupedField => <DatePicker key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} />,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value ? formatDate(groupedField.settings.dateFormat, value) : '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {
|
||||
const date = parseDate(groupedField.settings.dateFormat, data[getFieldColumn(groupedField)]);
|
||||
data[getFieldColumn(groupedField)] = date;
|
||||
},
|
||||
validate: (groupedField, state) => {
|
||||
const value = state.getIn([getFieldColumn(groupedField), 'value']);
|
||||
const date = parseDate(groupedField.settings.dateFormat, value);
|
||||
if (value !== '' && !date) {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], null);
|
||||
}
|
||||
},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const birthdayFieldType = {
|
||||
form: groupedField => <DatePicker key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} dateFormat={groupedField.settings.dateFormat} birthday />,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value ? formatBirthday(groupedField.settings.dateFormat, value) : '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {
|
||||
const date = parseBirthday(groupedField.settings.dateFormat, data[getFieldColumn(groupedField)]);
|
||||
data[getFieldColumn(groupedField)] = date;
|
||||
},
|
||||
validate: (groupedField, state) => {
|
||||
const value = state.getIn([getFieldColumn(groupedField), 'value']);
|
||||
const date = parseBirthday(groupedField.settings.dateFormat, value);
|
||||
if (value !== '' && !date) {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], t('dateIsInvalid'));
|
||||
} else {
|
||||
state.setIn([getFieldColumn(groupedField), 'error'], null);
|
||||
}
|
||||
},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const jsonFieldType = {
|
||||
form: groupedField => <ACEEditor key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} label={groupedField.name} mode="json" height="300px"/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = value || '';
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: false
|
||||
};
|
||||
|
||||
const optionFieldType = {
|
||||
form: groupedField => <CheckBox key={getFieldColumn(groupedField)} id={getFieldColumn(groupedField)} text={groupedField.settings.checkedLabel} label={groupedField.name}/>,
|
||||
assignFormData: (groupedField, data) => {
|
||||
const value = data[getFieldColumn(groupedField)];
|
||||
data[getFieldColumn(groupedField)] = !!value;
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = false;
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: true
|
||||
};
|
||||
|
||||
const enumSingleFieldType = componentType => ({
|
||||
form: groupedField => React.createElement(componentType, { key: getFieldColumn(groupedField), id: getFieldColumn(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
|
||||
assignFormData: (groupedField, data) => {
|
||||
if (data[getFieldColumn(groupedField)] === null) {
|
||||
if (groupedField.default_value) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.default_value;
|
||||
} else if (groupedField.settings.options.length > 0) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.settings.options[0].key;
|
||||
} else {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
}
|
||||
}
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
if (groupedField.default_value) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.default_value;
|
||||
} else if (groupedField.settings.options.length > 0) {
|
||||
data[getFieldColumn(groupedField)] = groupedField.settings.options[0].key;
|
||||
} else {
|
||||
data[getFieldColumn(groupedField)] = '';
|
||||
}
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: false
|
||||
});
|
||||
|
||||
const enumMultipleFieldType = componentType => ({
|
||||
form: groupedField => React.createElement(componentType, { key: getFieldColumn(groupedField), id: getFieldColumn(groupedField), label: groupedField.name, options: groupedField.settings.options }, null),
|
||||
assignFormData: (groupedField, data) => {
|
||||
if (data[getFieldColumn(groupedField)] === null) {
|
||||
data[getFieldColumn(groupedField)] = [];
|
||||
}
|
||||
},
|
||||
initFormData: (groupedField, data) => {
|
||||
data[getFieldColumn(groupedField)] = [];
|
||||
},
|
||||
assignEntity: (groupedField, data) => {},
|
||||
validate: (groupedField, state) => {},
|
||||
indexable: false
|
||||
});
|
||||
|
||||
|
||||
groupedFieldTypes.text = stringFieldType(false);
|
||||
groupedFieldTypes.website = stringFieldType(false);
|
||||
groupedFieldTypes.longtext = stringFieldType(true);
|
||||
groupedFieldTypes.gpg = stringFieldType(true);
|
||||
groupedFieldTypes.number = numberFieldType;
|
||||
groupedFieldTypes.date = dateFieldType;
|
||||
groupedFieldTypes.birthday = birthdayFieldType;
|
||||
groupedFieldTypes.json = jsonFieldType;
|
||||
groupedFieldTypes.option = optionFieldType;
|
||||
groupedFieldTypes['dropdown-enum'] = enumSingleFieldType(Dropdown);
|
||||
groupedFieldTypes['radio-enum'] = enumSingleFieldType(RadioGroup);
|
||||
|
||||
// Here we rely on the fact the model/groupedFields and model/subscriptions preprocess the groupedField info and subscription
|
||||
// such that the grouped entries behave the same as the enum entries
|
||||
groupedFieldTypes['checkbox-grouped'] = enumMultipleFieldType(CheckBoxGroup);
|
||||
groupedFieldTypes['radio-grouped'] = enumSingleFieldType(RadioGroup);
|
||||
groupedFieldTypes['dropdown-grouped'] = enumSingleFieldType(Dropdown);
|
||||
|
||||
return groupedFieldTypes;
|
||||
}
|
|
@ -82,6 +82,8 @@ export default class Login extends Component {
|
|||
/* This ensures we get config for the authenticated user */
|
||||
window.location = nextUrl;
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
||||
this.setFormStatusMessage('warning', t('pleaseEnterYourCredentialsAndTryAgain'));
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -77,19 +77,21 @@ export default class CUD extends Component {
|
|||
|
||||
@withAsyncErrorHandler
|
||||
async loadTreeData() {
|
||||
const response = await axios.get(getUrl('rest/namespaces-tree'));
|
||||
const data = response.data;
|
||||
for (const root of data) {
|
||||
root.expanded = true;
|
||||
}
|
||||
if (!this.isEditGlobal()) {
|
||||
const response = await axios.get(getUrl('rest/namespaces-tree'));
|
||||
const data = response.data;
|
||||
for (const root of data) {
|
||||
root.expanded = true;
|
||||
}
|
||||
|
||||
if (this.props.entity && !this.isEditGlobal()) {
|
||||
this.removeNsIdSubtree(data);
|
||||
}
|
||||
if (this.props.entity && !this.isEditGlobal()) {
|
||||
this.removeNsIdSubtree(data);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
treeData: data
|
||||
});
|
||||
this.setState({
|
||||
treeData: data
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -103,10 +105,8 @@ export default class CUD extends Component {
|
|||
});
|
||||
}
|
||||
|
||||
if (!this.isEditGlobal()) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.loadTreeData();
|
||||
}
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.loadTreeData();
|
||||
}
|
||||
|
||||
localValidateFormValues(state) {
|
||||
|
@ -127,7 +127,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -143,10 +143,26 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/namespaces', 'success', t('namespaceSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/namespaces/${this.props.entity.id}`);
|
||||
await this.loadTreeData();
|
||||
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Namespace updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/namespaces/${submitResult}/edit`, 'success', t('Namespace created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -205,7 +221,8 @@ export default class CUD extends Component {
|
|||
<TreeTableSelect id="namespace" label={t('parentNamespace')} data={this.state.treeData}/>}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/namespaces/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -4,6 +4,7 @@ import React from 'react';
|
|||
import CUD from './CUD';
|
||||
import List from './List';
|
||||
import Share from '../shares/Share';
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
|
||||
|
||||
function getMenus(t) {
|
||||
return {
|
||||
|
@ -13,7 +14,7 @@ function getMenus(t) {
|
|||
panelComponent: List,
|
||||
children: {
|
||||
':namespaceId([0-9]+)': {
|
||||
title: resolved => t('namespaceName', {name: resolved.namespace.name}),
|
||||
title: resolved => t('namespaceName', {name: ellipsizeBreadcrumbLabel(resolved.namespace.name)}),
|
||||
resolve: {
|
||||
namespace: params => `rest/namespaces/${params.namespaceId}`
|
||||
},
|
||||
|
|
|
@ -82,13 +82,16 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
for (const key in data.params) {
|
||||
data[`param_${key}`] = data.params[key];
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
for (const key in data.params) {
|
||||
data[`param_${key}`] = data.params[key];
|
||||
}
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
name: '',
|
||||
|
@ -145,7 +148,7 @@ export default class CUD extends Component {
|
|||
validateNamespace(t, state);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
if (this.getFormValue('report_template') && !this.getFormValue('user_fields')) {
|
||||
|
@ -165,7 +168,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const params = {};
|
||||
|
||||
for (const spec of data.user_fields) {
|
||||
|
@ -178,8 +181,22 @@ export default class CUD extends Component {
|
|||
data.params = params;
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/reports', 'success', t('reportSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/reports', 'success', t('Report updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/reports/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Report updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/reports', 'success', t('Report created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/reports/${submitResult}/edit`, 'success', t('Report created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -274,7 +291,8 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete &&
|
||||
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/${this.props.entity.id}/delete`}/>
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import ReportTemplatesList from './templates/List';
|
|||
import Share from '../shares/Share';
|
||||
import {ReportState} from '../../../shared/reports';
|
||||
import mailtrainConfig from 'mailtrainConfig';
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
|
||||
|
||||
|
||||
function getMenus(t) {
|
||||
|
@ -19,7 +20,7 @@ function getMenus(t) {
|
|||
panelComponent: ReportsList,
|
||||
children: {
|
||||
':reportId([0-9]+)': {
|
||||
title: resolved => t('reportName-1', {name: resolved.report.name}),
|
||||
title: resolved => t('reportName-1', {name: ellipsizeBreadcrumbLabel(resolved.report.name)}),
|
||||
resolve: {
|
||||
report: params => `rest/reports/${params.reportId}`
|
||||
},
|
||||
|
@ -66,7 +67,7 @@ function getMenus(t) {
|
|||
panelComponent: ReportTemplatesList,
|
||||
children: {
|
||||
':templateId([0-9]+)': {
|
||||
title: resolved => t('templateName', {name: resolved.template.name}),
|
||||
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
|
||||
resolve: {
|
||||
template: params => `rest/report-templates/${params.templateId}`
|
||||
},
|
||||
|
|
|
@ -262,15 +262,7 @@ export default class CUD extends Component {
|
|||
validateNamespace(t, state);
|
||||
}
|
||||
|
||||
async submitAndStay() {
|
||||
await this.formHandleChangedError(async () => await this.doSubmit(true));
|
||||
}
|
||||
|
||||
async submitAndLeave() {
|
||||
await this.formHandleChangedError(async () => await this.doSubmit(false));
|
||||
}
|
||||
|
||||
async doSubmit(stay) {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -285,15 +277,23 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
|
||||
|
||||
if (submitSuccessful) {
|
||||
if (stay) {
|
||||
await this.getFormValuesFromURL(`rest/report-templates/${this.props.entity.id}`);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('reportTemplateSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/reports/templates', 'success', t('Report template updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/report-templates/${this.props.entity.id}`);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Report template updated'));
|
||||
}
|
||||
} else {
|
||||
this.navigateToWithFlashMessage('/reports/templates', 'success', t('reportTemplateSaved'));
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/reports/templates', 'success', t('Report template created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/reports/templates/${submitResult}/edit`, 'success', t('Report template created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
@ -321,7 +321,7 @@ export default class CUD extends Component {
|
|||
|
||||
<Title>{isEdit ? t('editReportTemplate') : t('createReportTemplate')}</Title>
|
||||
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<InputField id="name" label={t('name')}/>
|
||||
<TextArea id="description" label={t('description')}/>
|
||||
<Dropdown id="mime_type" label={t('type')} options={[{key: 'text/html', label: t('html')}, {key: 'text/csv', label: t('csv')}]}/>
|
||||
|
@ -330,19 +330,13 @@ export default class CUD extends Component {
|
|||
<ACEEditor id="js" height="700px" mode="javascript" label={t('dataProcessingCode')} help={<Trans i18nKey="writeTheBodyOfTheJavaScriptFunctionWith">Write the body of the JavaScript function with signature <code>async function(inputs)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/>
|
||||
<ACEEditor id="hbs" height="700px" mode="handlebars" label={t('renderingTemplate')} help={<Trans i18nKey="useHtmlWithHandlebarsSyntaxSee">Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/>
|
||||
|
||||
{isEdit ?
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndStay')} onClickAsync={::this.submitAndStay}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')}/>
|
||||
{canDelete &&
|
||||
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/templates/${this.props.entity.id}/delete`}/>
|
||||
}
|
||||
</ButtonRow>
|
||||
:
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
</ButtonRow>
|
||||
}
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete &&
|
||||
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/reports/templates/${this.props.entity.id}/delete`}/>
|
||||
}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -108,7 +108,7 @@ class Root extends Component {
|
|||
<ul className="navbar-nav mt-navbar-nav-left">
|
||||
{topLevelMenu}
|
||||
<NavDropdown label={t('administration')}>
|
||||
<DropdownLink to="/users">{t('users')}</DropdownLink>
|
||||
{mailtrainConfig.globalPermissions.displayManageUsers && <DropdownLink to="/users">{t('users')}</DropdownLink>}
|
||||
<DropdownLink to="/namespaces">{t('namespaces')}</DropdownLink>
|
||||
{mailtrainConfig.globalPermissions.manageSettings && <DropdownLink to="/settings">{t('globalSettings')}</DropdownLink>}
|
||||
<DropdownLink to="/send-configurations">{t('sendConfigurations')}</DropdownLink>
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
@import url('https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,400i,700,700i|Ubuntu:300,300i,400,400i,700,700i&subset=latin-ext');
|
||||
|
||||
$font-family-sans-serif: 'Ubuntu', sans-serif;
|
||||
$font-family-monospace: 'Ubuntu Mono', monospace;
|
||||
|
||||
$fa-font-path: "../static-npm/fontawesome";
|
||||
$enable-print-styles: false;
|
||||
|
||||
@import "./variables.scss";
|
||||
@import "node_modules/@coreui/coreui/scss/coreui.scss";
|
||||
|
@ -13,6 +19,19 @@ $fa-font-path: "../static-npm/fontawesome";
|
|||
body.mailtrain {
|
||||
background-color: white;
|
||||
|
||||
&.sandbox {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&.inside-iframe {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sandbox-loading-message {
|
||||
// The 40 px below corresponds to the height in in UntrustedContentHost.render
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
border-bottom: none 0px;
|
||||
}
|
||||
|
|
|
@ -40,6 +40,8 @@ import {
|
|||
import styles
|
||||
from "../lib/styles.scss";
|
||||
|
||||
import sendConfigurationsStyles from "./styles.scss";
|
||||
|
||||
import mailtrainConfig
|
||||
from 'mailtrainConfig';
|
||||
import {withComponentMixins} from "../lib/decorator-helpers";
|
||||
|
@ -80,15 +82,16 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
this.mailerTypes[data.mailer_type].afterLoad(data);
|
||||
data.verpEnabled = !!data.verp_hostname;
|
||||
data.verp_hostname = data.verp_hostname || '';
|
||||
data.verp_disable_sender_header = data.verpEnabled ? !!data.verp_disable_sender_header : false;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
this.mailerTypes[data.mailer_type].afterLoad(data);
|
||||
data.verpEnabled = !!data.verp_hostname;
|
||||
data.verp_hostname = data.verp_hostname || '';
|
||||
data.verp_disable_sender_header = data.verpEnabled ? !!data.verp_disable_sender_header : false;
|
||||
});
|
||||
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
name: '',
|
||||
|
@ -142,7 +145,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -157,7 +160,7 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
this.mailerTypes[data.mailer_type].beforeSave(data);
|
||||
if (!data.verpEnabled) {
|
||||
data.verp_hostname = null;
|
||||
|
@ -165,8 +168,22 @@ export default class CUD extends Component {
|
|||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/send-configurations', 'success', t('sendConfigurationSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/send-configurations', 'success', t('Send configuration updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/send-configurations-private/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Send configuration updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/send-configurations', 'success', t('Send configuration created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/send-configurations/${submitResult}/edit`, 'success', t('Send configuration created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -216,13 +233,13 @@ export default class CUD extends Component {
|
|||
|
||||
<Fieldset label={t('emailHeader')}>
|
||||
<InputField id="from_email" label={t('defaultFromEmail')}/>
|
||||
<CheckBox id="from_email_overridable" text={t('overridable')}/>
|
||||
<CheckBox id="from_email_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
|
||||
<InputField id="from_name" label={t('defaultFromName')}/>
|
||||
<CheckBox id="from_name_overridable" text={t('overridable')}/>
|
||||
<CheckBox id="from_name_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
|
||||
<InputField id="reply_to" label={t('defaultReplytoEmail')}/>
|
||||
<CheckBox id="reply_to_overridable" text={t('overridable')}/>
|
||||
<CheckBox id="reply_to_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
|
||||
<InputField id="subject" label={t('subject')}/>
|
||||
<CheckBox id="subject_overridable" text={t('overridable')}/>
|
||||
<CheckBox id="subject_overridable" text={t('overridable')} className={sendConfigurationsStyles.overridableCheckbox}/>
|
||||
<InputField id="x_mailer" label={t('xMailer')}/>
|
||||
</Fieldset>
|
||||
|
||||
|
@ -247,7 +264,8 @@ export default class CUD extends Component {
|
|||
<hr/>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete &&
|
||||
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/send-configurations/${this.props.entity.id}/delete`}/>
|
||||
}
|
||||
|
|
|
@ -1,337 +1,337 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
|
||||
import {MailerType, ZoneMTAType} from "../../../shared/send-configurations";
|
||||
import {
|
||||
CheckBox,
|
||||
Dropdown,
|
||||
Fieldset,
|
||||
InputField,
|
||||
TextArea
|
||||
} from "../lib/form";
|
||||
import {Trans} from "react-i18next";
|
||||
import styles from "./styles.scss";
|
||||
import mailtrainConfig from 'mailtrainConfig';
|
||||
|
||||
export const mailerTypesOrder = [
|
||||
MailerType.ZONE_MTA,
|
||||
MailerType.GENERIC_SMTP,
|
||||
MailerType.AWS_SES
|
||||
];
|
||||
|
||||
export function getMailerTypes(t) {
|
||||
const mailerTypes = {};
|
||||
|
||||
function initFieldsIfMissing(mutStateData, mailerType) {
|
||||
const initVals = mailerTypes[mailerType].initData();
|
||||
|
||||
for (const key in initVals) {
|
||||
if (!mutStateData.hasIn([key])) {
|
||||
mutStateData.setIn([key, 'value'], initVals[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearBeforeSave(data) {
|
||||
for (const mailerKey in mailerTypes) {
|
||||
const initVals = mailerTypes[mailerKey].initData();
|
||||
for (const fieldKey in initVals) {
|
||||
delete data[fieldKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateNumber(state, field, label, emptyAllowed = false) {
|
||||
const value = state.getIn([field, 'value']);
|
||||
if (typeof value === 'string' && value.trim() === '' && !emptyAllowed) { // After load, the numerical values can be still numbers
|
||||
state.setIn([field, 'error'], t('labelMustNotBeEmpty', {label}));
|
||||
} else if (isNaN(value)) {
|
||||
state.setIn([field, 'error'], t('labelMustBeANumber', {label}));
|
||||
} else {
|
||||
state.setIn([field, 'error'], null);
|
||||
}
|
||||
}
|
||||
|
||||
function getInitCommon() {
|
||||
return {
|
||||
maxConnections: '5',
|
||||
throttling: '',
|
||||
logTransactions: false
|
||||
};
|
||||
}
|
||||
|
||||
function getInitGenericSMTP() {
|
||||
return {
|
||||
...getInitCommon(),
|
||||
smtpHostname: '',
|
||||
smtpPort: '',
|
||||
smtpEncryption: 'NONE',
|
||||
smtpUseAuth: false,
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
smtpAllowSelfSigned: false,
|
||||
smtpMaxMessages: '100'
|
||||
};
|
||||
}
|
||||
|
||||
function afterLoadCommon(data) {
|
||||
data.maxConnections = data.mailer_settings.maxConnections;
|
||||
data.throttling = data.mailer_settings.throttling || '';
|
||||
data.logTransactions = data.mailer_settings.logTransactions;
|
||||
}
|
||||
|
||||
function afterLoadGenericSMTP(data) {
|
||||
afterLoadCommon(data);
|
||||
data.smtpHostname = data.mailer_settings.hostname;
|
||||
data.smtpPort = data.mailer_settings.port || '';
|
||||
data.smtpEncryption = data.mailer_settings.encryption;
|
||||
data.smtpUseAuth = data.mailer_settings.useAuth;
|
||||
data.smtpUser = data.mailer_settings.user;
|
||||
data.smtpPassword = data.mailer_settings.password;
|
||||
data.smtpAllowSelfSigned = data.mailer_settings.allowSelfSigned;
|
||||
data.smtpMaxMessages = data.mailer_settings.maxMessages;
|
||||
}
|
||||
|
||||
function beforeSaveCommon(data) {
|
||||
data.mailer_settings = {};
|
||||
data.mailer_settings.maxConnections = Number(data.maxConnections);
|
||||
data.mailer_settings.throttling = Number(data.throttling);
|
||||
data.mailer_settings.logTransactions = data.logTransactions;
|
||||
}
|
||||
|
||||
function beforeSaveGenericSMTP(data, builtin = false) {
|
||||
beforeSaveCommon(data);
|
||||
|
||||
if (!builtin) {
|
||||
data.mailer_settings.hostname = data.smtpHostname;
|
||||
data.mailer_settings.port = Number(data.smtpPort);
|
||||
data.mailer_settings.encryption = data.smtpEncryption;
|
||||
data.mailer_settings.useAuth = data.smtpUseAuth;
|
||||
data.mailer_settings.user = data.smtpUser;
|
||||
data.mailer_settings.password = data.smtpPassword;
|
||||
data.mailer_settings.allowSelfSigned = data.smtpAllowSelfSigned;
|
||||
}
|
||||
|
||||
data.mailer_settings.maxMessages = Number(data.smtpMaxMessages);
|
||||
}
|
||||
|
||||
function validateCommon(state) {
|
||||
validateNumber(state, 'maxConnections', 'Max connections');
|
||||
validateNumber(state, 'throttling', 'Throttling', true);
|
||||
}
|
||||
|
||||
function validateGenericSMTP(state) {
|
||||
validateCommon(state);
|
||||
validateNumber(state, 'smtpPort', 'Port', true);
|
||||
validateNumber(state, 'smtpMaxMessages', 'Max messages');
|
||||
}
|
||||
|
||||
const typeNames = {
|
||||
[MailerType.GENERIC_SMTP]: t('genericSmtp'),
|
||||
[MailerType.ZONE_MTA]: t('zoneMta'),
|
||||
[MailerType.AWS_SES]: t('amazonSes')
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ key: MailerType.GENERIC_SMTP, label: typeNames[MailerType.GENERIC_SMTP]},
|
||||
{ key: MailerType.ZONE_MTA, label: typeNames[MailerType.ZONE_MTA]},
|
||||
{ key: MailerType.AWS_SES, label: typeNames[MailerType.AWS_SES]}
|
||||
];
|
||||
|
||||
const smtpEncryptionOptions = [
|
||||
{ key: 'NONE', label: t('doNotUseEncryption')},
|
||||
{ key: 'TLS', label: t('useTls –UsuallySelectedForPort465')},
|
||||
{ key: 'STARTTLS', label: t('useStarttls –UsuallySelectedForPort587')}
|
||||
];
|
||||
|
||||
const sesRegionOptions = [
|
||||
{ key: 'us-east-1', label: t('useast1')},
|
||||
{ key: 'us-west-2', label: t('uswest2')},
|
||||
{ key: 'eu-west-1', label: t('euwest1')}
|
||||
];
|
||||
|
||||
const zoneMtaTypeOptions = [];
|
||||
|
||||
if (mailtrainConfig.builtinZoneMTAEnabled) {
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.BUILTIN, label: t('builtinZoneMta')});
|
||||
}
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF, label: t('dynamicConfigurationOfDkimKeysViaZoneMt')});
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.WITH_HTTP_CONF, label: t('dynamicConfigurationOfDkimKeysViaZoneMt-1')});
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.REGULAR, label: t('noDynamicConfigurationOfDkimKeys')});
|
||||
|
||||
mailerTypes[MailerType.GENERIC_SMTP] = {
|
||||
typeName: typeNames[MailerType.GENERIC_SMTP],
|
||||
getForm: owner =>
|
||||
<div>
|
||||
<Fieldset label={t('mailerSettings')}>
|
||||
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
|
||||
<InputField id="smtpHostname" label={t('hostname')} placeholder={t('hostnameEgSmtpexamplecom')}/>
|
||||
<InputField id="smtpPort" label={t('port')} placeholder={t('portEg465AutodetectedIfLeftBlank')}/>
|
||||
<Dropdown id="smtpEncryption" label={t('encryption')} options={smtpEncryptionOptions}/>
|
||||
<CheckBox id="smtpUseAuth" text={t('enableSmtpAuthentication')}/>
|
||||
{ owner.getFormValue('smtpUseAuth') &&
|
||||
<div>
|
||||
<InputField id="smtpUser" label={t('username')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
<InputField id="smtpPassword" type="password" label={t('password')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
</div>
|
||||
}
|
||||
</Fieldset>
|
||||
<Fieldset label={t('advancedMailerSettings')}>
|
||||
<CheckBox id="logTransactions" text={t('logSmtpTransactions')}/>
|
||||
<CheckBox id="smtpAllowSelfSigned" text={t('allowSelfsignedCertificates')}/>
|
||||
<InputField id="maxConnections" label={t('maxConnections')} placeholder={t('theCountOfMaxConnectionsEg10')} help={t('theCountOfMaximumSimultaneousConnections')}/>
|
||||
<InputField id="smtpMaxMessages" label={t('maxMessages')} placeholder={t('theCountOfMaxMessagesEg100')} help={t('theNumberOfMessagesToSendThroughASingle')}/>
|
||||
<InputField id="throttling" label={t('throttling')} placeholder={t('messagesPerHourEg1000')} help={t('maximumNumberOfMessagesToSendInAnHour')}/>
|
||||
</Fieldset>
|
||||
</div>,
|
||||
initData: () => ({
|
||||
...getInitGenericSMTP()
|
||||
}),
|
||||
afterLoad: data => {
|
||||
afterLoadGenericSMTP(data);
|
||||
},
|
||||
beforeSave: data => {
|
||||
beforeSaveGenericSMTP(data);
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {
|
||||
initFieldsIfMissing(mutState, MailerType.GENERIC_SMTP);
|
||||
},
|
||||
validate: state => {
|
||||
validateGenericSMTP(state);
|
||||
}
|
||||
};
|
||||
|
||||
mailerTypes[MailerType.ZONE_MTA] = {
|
||||
typeName: typeNames[MailerType.ZONE_MTA],
|
||||
getForm: owner => {
|
||||
const zoneMtaType = Number.parseInt(owner.getFormValue('zoneMtaType'));
|
||||
return (
|
||||
<div>
|
||||
<Fieldset label={t('mailerSettings')}>
|
||||
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
|
||||
<Dropdown id="zoneMtaType" label={t('dynamicConfiguration')} options={zoneMtaTypeOptions}/>
|
||||
{(zoneMtaType === ZoneMTAType.REGULAR || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
|
||||
<div>
|
||||
<InputField id="smtpHostname" label={t('hostname')} placeholder={t('hostnameEgSmtpexamplecom')}/>
|
||||
<InputField id="smtpPort" label={t('port')} placeholder={t('portEg465AutodetectedIfLeftBlank')}/>
|
||||
<Dropdown id="smtpEncryption" label={t('encryption')} options={smtpEncryptionOptions}/>
|
||||
<CheckBox id="smtpUseAuth" text={t('enableSmtpAuthentication')}/>
|
||||
{ owner.getFormValue('smtpUseAuth') &&
|
||||
<div>
|
||||
<InputField id="smtpUser" label={t('username')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
<InputField id="smtpPassword" type="password" label={t('password')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Fieldset>
|
||||
{(zoneMtaType === ZoneMTAType.BUILTIN || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
|
||||
<Fieldset label={t('dkimSigning')}>
|
||||
<Trans i18nKey="ifYouAreUsingZoneMtaThenMailtrainCan"><p>If you are using ZoneMTA then Mailtrain can provide a DKIM key for signing all outgoing messages.</p></Trans>
|
||||
<Trans i18nKey="doNotUseSensitiveKeysHereThePrivateKeyIs"><p className="text-warning">Do not use sensitive keys here. The private key is not encrypted in the database.</p></Trans>
|
||||
{zoneMtaType === ZoneMTAType.WITH_HTTP_CONF &&
|
||||
<InputField id="dkimApiKey" label={t('zoneMtaDkimApiKey')} help={t('secretValueKnownToZoneMtaForRequesting')}/>
|
||||
}
|
||||
<InputField id="dkimDomain" label={t('dkimDomain')} help={t('leaveBlankToUseTheSenderEmailAddress')}/>
|
||||
<InputField id="dkimSelector" label={t('dkimKeySelector')} help={t('signingIsDisabledWithoutAValidSelector')}/>
|
||||
<TextArea id="dkimPrivateKey" className={styles.dkimPrivateKey} label={t('dkimPrivateKey')} placeholder={t('beginsWithBeginRsaPrivateKey')} help={t('signingIsDisabledWithoutAValidPrivateKey')}/>
|
||||
</Fieldset>
|
||||
}
|
||||
<Fieldset label={t('advancedMailerSettings')}>
|
||||
<CheckBox id="logTransactions" text={t('logSmtpTransactions')}/>
|
||||
<CheckBox id="smtpAllowSelfSigned" text={t('allowSelfsignedCertificates')}/>
|
||||
<InputField id="maxConnections" label={t('maxConnections')} placeholder={t('theCountOfMaxConnectionsEg10')} help={t('theCountOfMaximumSimultaneousConnections')}/>
|
||||
<InputField id="smtpMaxMessages" label={t('maxMessages')} placeholder={t('theCountOfMaxMessagesEg100')} help={t('theNumberOfMessagesToSendThroughASingle')}/>
|
||||
<InputField id="throttling" label={t('throttling')} placeholder={t('messagesPerHourEg1000')} help={t('maximumNumberOfMessagesToSendInAnHour')}/>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
initData: () => ({
|
||||
...getInitGenericSMTP(),
|
||||
zoneMtaType: mailtrainConfig.builtinZoneMTAEnabled ? ZoneMTAType.BUILTIN : ZoneMTAType.REGULAR,
|
||||
dkimApiKey: '',
|
||||
dkimDomain: '',
|
||||
dkimSelector: '',
|
||||
dkimPrivateKey: ''
|
||||
}),
|
||||
afterLoad: data => {
|
||||
afterLoadGenericSMTP(data);
|
||||
data.zoneMtaType = data.mailer_settings.zoneMtaType;
|
||||
data.dkimApiKey = data.mailer_settings.dkimApiKey;
|
||||
data.dkimDomain = data.mailer_settings.dkimDomain;
|
||||
data.dkimSelector = data.mailer_settings.dkimSelector;
|
||||
data.dkimPrivateKey = data.mailer_settings.dkimPrivateKey;
|
||||
},
|
||||
beforeSave: data => {
|
||||
const zoneMtaType = Number.parseInt(data.zoneMtaType);
|
||||
|
||||
beforeSaveGenericSMTP(data, zoneMtaType === ZoneMTAType.BUILTIN);
|
||||
|
||||
data.mailer_settings.zoneMtaType = zoneMtaType;
|
||||
if (zoneMtaType === ZoneMTAType.BUILTIN || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF) {
|
||||
data.mailer_settings.dkimDomain = data.dkimDomain;
|
||||
data.mailer_settings.dkimSelector = data.dkimSelector;
|
||||
data.mailer_settings.dkimPrivateKey = data.dkimPrivateKey;
|
||||
}
|
||||
if (zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) {
|
||||
data.mailer_settings.dkimApiKey = data.dkimApiKey;
|
||||
}
|
||||
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {
|
||||
initFieldsIfMissing(mutState, MailerType.ZONE_MTA);
|
||||
},
|
||||
validate: state => {
|
||||
validateGenericSMTP(state);
|
||||
}
|
||||
};
|
||||
|
||||
mailerTypes[MailerType.AWS_SES] = {
|
||||
typeName: typeNames[MailerType.AWS_SES],
|
||||
getForm: owner =>
|
||||
<div>
|
||||
<Fieldset label={t('mailerSettings')}>
|
||||
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
|
||||
<InputField id="sesKey" label={t('accessKey')} placeholder={t('awsAccessKeyId')}/>
|
||||
<InputField id="sesSecret" label={t('port')} placeholder={t('awsSecretAccessKey')}/>
|
||||
<Dropdown id="sesRegion" label={t('region')} options={sesRegionOptions}/>
|
||||
</Fieldset>
|
||||
<Fieldset label={t('advancedMailerSettings')}>
|
||||
<CheckBox id="logTransactions" text={t('logSmtpTransactions')}/>
|
||||
<InputField id="maxConnections" label={t('maxConnections')} placeholder={t('theCountOfMaxConnectionsEg10')} help={t('theCountOfMaximumSimultaneousConnections')}/>
|
||||
<InputField id="throttling" label={t('throttling')} placeholder={t('messagesPerHourEg1000')} help={t('maximumNumberOfMessagesToSendInAnHour')}/>
|
||||
</Fieldset>
|
||||
</div>,
|
||||
initData: () => ({
|
||||
...getInitCommon(),
|
||||
sesKey: '',
|
||||
sesSecret: '',
|
||||
sesRegion: ''
|
||||
}),
|
||||
afterLoad: data => {
|
||||
afterLoadCommon(data);
|
||||
data.sesKey = data.mailer_settings.key;
|
||||
data.sesSecret = data.mailer_settings.secret;
|
||||
data.sesRegion = data.mailer_settings.region;
|
||||
},
|
||||
beforeSave: data => {
|
||||
beforeSaveCommon(data);
|
||||
data.mailer_settings.key = data.sesKey;
|
||||
data.mailer_settings.secret = data.sesSecret;
|
||||
data.mailer_settings.region = data.sesRegion;
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {
|
||||
initFieldsIfMissing(mutState, MailerType.AWS_SES);
|
||||
},
|
||||
validate: state => {
|
||||
validateCommon(state);
|
||||
}
|
||||
};
|
||||
|
||||
return mailerTypes;
|
||||
}
|
||||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
|
||||
import {MailerType, ZoneMTAType} from "../../../shared/send-configurations";
|
||||
import {
|
||||
CheckBox,
|
||||
Dropdown,
|
||||
Fieldset,
|
||||
InputField,
|
||||
TextArea
|
||||
} from "../lib/form";
|
||||
import {Trans} from "react-i18next";
|
||||
import styles from "./styles.scss";
|
||||
import mailtrainConfig from 'mailtrainConfig';
|
||||
|
||||
export const mailerTypesOrder = [
|
||||
MailerType.ZONE_MTA,
|
||||
MailerType.GENERIC_SMTP,
|
||||
MailerType.AWS_SES
|
||||
];
|
||||
|
||||
export function getMailerTypes(t) {
|
||||
const mailerTypes = {};
|
||||
|
||||
function initFieldsIfMissing(mutStateData, mailerType) {
|
||||
const initVals = mailerTypes[mailerType].initData();
|
||||
|
||||
for (const key in initVals) {
|
||||
if (!mutStateData.hasIn([key])) {
|
||||
mutStateData.setIn([key, 'value'], initVals[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearBeforeSave(data) {
|
||||
for (const mailerKey in mailerTypes) {
|
||||
const initVals = mailerTypes[mailerKey].initData();
|
||||
for (const fieldKey in initVals) {
|
||||
delete data[fieldKey];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateNumber(state, field, label, emptyAllowed = false) {
|
||||
const value = state.getIn([field, 'value']);
|
||||
if (typeof value === 'string' && value.trim() === '' && !emptyAllowed) { // After load, the numerical values can be still numbers
|
||||
state.setIn([field, 'error'], t('labelMustNotBeEmpty', {label}));
|
||||
} else if (isNaN(value)) {
|
||||
state.setIn([field, 'error'], t('labelMustBeANumber', {label}));
|
||||
} else {
|
||||
state.setIn([field, 'error'], null);
|
||||
}
|
||||
}
|
||||
|
||||
function getInitCommon() {
|
||||
return {
|
||||
maxConnections: '5',
|
||||
throttling: '',
|
||||
logTransactions: false
|
||||
};
|
||||
}
|
||||
|
||||
function getInitGenericSMTP() {
|
||||
return {
|
||||
...getInitCommon(),
|
||||
smtpHostname: '',
|
||||
smtpPort: '',
|
||||
smtpEncryption: 'NONE',
|
||||
smtpUseAuth: false,
|
||||
smtpUser: '',
|
||||
smtpPassword: '',
|
||||
smtpAllowSelfSigned: false,
|
||||
smtpMaxMessages: '100'
|
||||
};
|
||||
}
|
||||
|
||||
function afterLoadCommon(data) {
|
||||
data.maxConnections = data.mailer_settings.maxConnections;
|
||||
data.throttling = data.mailer_settings.throttling || '';
|
||||
data.logTransactions = data.mailer_settings.logTransactions;
|
||||
}
|
||||
|
||||
function afterLoadGenericSMTP(data) {
|
||||
afterLoadCommon(data);
|
||||
data.smtpHostname = data.mailer_settings.hostname;
|
||||
data.smtpPort = data.mailer_settings.port || '';
|
||||
data.smtpEncryption = data.mailer_settings.encryption;
|
||||
data.smtpUseAuth = data.mailer_settings.useAuth;
|
||||
data.smtpUser = data.mailer_settings.user;
|
||||
data.smtpPassword = data.mailer_settings.password;
|
||||
data.smtpAllowSelfSigned = data.mailer_settings.allowSelfSigned;
|
||||
data.smtpMaxMessages = data.mailer_settings.maxMessages;
|
||||
}
|
||||
|
||||
function beforeSaveCommon(data) {
|
||||
data.mailer_settings = {};
|
||||
data.mailer_settings.maxConnections = Number(data.maxConnections);
|
||||
data.mailer_settings.throttling = Number(data.throttling);
|
||||
data.mailer_settings.logTransactions = data.logTransactions;
|
||||
}
|
||||
|
||||
function beforeSaveGenericSMTP(data, builtin = false) {
|
||||
beforeSaveCommon(data);
|
||||
|
||||
if (!builtin) {
|
||||
data.mailer_settings.hostname = data.smtpHostname;
|
||||
data.mailer_settings.port = Number(data.smtpPort);
|
||||
data.mailer_settings.encryption = data.smtpEncryption;
|
||||
data.mailer_settings.useAuth = data.smtpUseAuth;
|
||||
data.mailer_settings.user = data.smtpUser;
|
||||
data.mailer_settings.password = data.smtpPassword;
|
||||
data.mailer_settings.allowSelfSigned = data.smtpAllowSelfSigned;
|
||||
}
|
||||
|
||||
data.mailer_settings.maxMessages = Number(data.smtpMaxMessages);
|
||||
}
|
||||
|
||||
function validateCommon(state) {
|
||||
validateNumber(state, 'maxConnections', 'Max connections');
|
||||
validateNumber(state, 'throttling', 'Throttling', true);
|
||||
}
|
||||
|
||||
function validateGenericSMTP(state) {
|
||||
validateCommon(state);
|
||||
validateNumber(state, 'smtpPort', 'Port', true);
|
||||
validateNumber(state, 'smtpMaxMessages', 'Max messages');
|
||||
}
|
||||
|
||||
const typeNames = {
|
||||
[MailerType.GENERIC_SMTP]: t('genericSmtp'),
|
||||
[MailerType.ZONE_MTA]: t('zoneMta'),
|
||||
[MailerType.AWS_SES]: t('amazonSes')
|
||||
};
|
||||
|
||||
const typeOptions = [
|
||||
{ key: MailerType.GENERIC_SMTP, label: typeNames[MailerType.GENERIC_SMTP]},
|
||||
{ key: MailerType.ZONE_MTA, label: typeNames[MailerType.ZONE_MTA]},
|
||||
{ key: MailerType.AWS_SES, label: typeNames[MailerType.AWS_SES]}
|
||||
];
|
||||
|
||||
const smtpEncryptionOptions = [
|
||||
{ key: 'NONE', label: t('doNotUseEncryption')},
|
||||
{ key: 'TLS', label: t('useTls –UsuallySelectedForPort465')},
|
||||
{ key: 'STARTTLS', label: t('useStarttls –UsuallySelectedForPort587')}
|
||||
];
|
||||
|
||||
const sesRegionOptions = [
|
||||
{ key: 'us-east-1', label: t('useast1')},
|
||||
{ key: 'us-west-2', label: t('uswest2')},
|
||||
{ key: 'eu-west-1', label: t('euwest1')}
|
||||
];
|
||||
|
||||
const zoneMtaTypeOptions = [];
|
||||
|
||||
if (mailtrainConfig.builtinZoneMTAEnabled) {
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.BUILTIN, label: t('builtinZoneMta')});
|
||||
}
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF, label: t('dynamicConfigurationOfDkimKeysViaZoneMt')});
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.WITH_HTTP_CONF, label: t('dynamicConfigurationOfDkimKeysViaZoneMt-1')});
|
||||
zoneMtaTypeOptions.push({ key: ZoneMTAType.REGULAR, label: t('noDynamicConfigurationOfDkimKeys')});
|
||||
|
||||
mailerTypes[MailerType.GENERIC_SMTP] = {
|
||||
typeName: typeNames[MailerType.GENERIC_SMTP],
|
||||
getForm: owner =>
|
||||
<div>
|
||||
<Fieldset label={t('mailerSettings')}>
|
||||
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
|
||||
<InputField id="smtpHostname" label={t('hostname')} placeholder={t('hostnameEgSmtpexamplecom')}/>
|
||||
<InputField id="smtpPort" label={t('port')} placeholder={t('portEg465AutodetectedIfLeftBlank')}/>
|
||||
<Dropdown id="smtpEncryption" label={t('encryption')} options={smtpEncryptionOptions}/>
|
||||
<CheckBox id="smtpUseAuth" text={t('enableSmtpAuthentication')}/>
|
||||
{ owner.getFormValue('smtpUseAuth') &&
|
||||
<div>
|
||||
<InputField id="smtpUser" label={t('username')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
<InputField id="smtpPassword" type="password" label={t('password')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
</div>
|
||||
}
|
||||
</Fieldset>
|
||||
<Fieldset label={t('advancedMailerSettings')}>
|
||||
<CheckBox id="logTransactions" text={t('logSmtpTransactions')}/>
|
||||
<CheckBox id="smtpAllowSelfSigned" text={t('allowSelfsignedCertificates')}/>
|
||||
<InputField id="maxConnections" label={t('maxConnections')} placeholder={t('theCountOfMaxConnectionsEg10')} help={t('theCountOfMaximumSimultaneousConnections')}/>
|
||||
<InputField id="smtpMaxMessages" label={t('maxMessages')} placeholder={t('theCountOfMaxMessagesEg100')} help={t('theNumberOfMessagesToSendThroughASingle')}/>
|
||||
<InputField id="throttling" label={t('throttling')} placeholder={t('messagesPerHourEg1000')} help={t('maximumNumberOfMessagesToSendInAnHour')}/>
|
||||
</Fieldset>
|
||||
</div>,
|
||||
initData: () => ({
|
||||
...getInitGenericSMTP()
|
||||
}),
|
||||
afterLoad: data => {
|
||||
afterLoadGenericSMTP(data);
|
||||
},
|
||||
beforeSave: data => {
|
||||
beforeSaveGenericSMTP(data);
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {
|
||||
initFieldsIfMissing(mutState, MailerType.GENERIC_SMTP);
|
||||
},
|
||||
validate: state => {
|
||||
validateGenericSMTP(state);
|
||||
}
|
||||
};
|
||||
|
||||
mailerTypes[MailerType.ZONE_MTA] = {
|
||||
typeName: typeNames[MailerType.ZONE_MTA],
|
||||
getForm: owner => {
|
||||
const zoneMtaType = Number.parseInt(owner.getFormValue('zoneMtaType'));
|
||||
return (
|
||||
<div>
|
||||
<Fieldset label={t('mailerSettings')}>
|
||||
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
|
||||
<Dropdown id="zoneMtaType" label={t('dynamicConfiguration')} options={zoneMtaTypeOptions}/>
|
||||
{(zoneMtaType === ZoneMTAType.REGULAR || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
|
||||
<div>
|
||||
<InputField id="smtpHostname" label={t('hostname')} placeholder={t('hostnameEgSmtpexamplecom')}/>
|
||||
<InputField id="smtpPort" label={t('port')} placeholder={t('portEg465AutodetectedIfLeftBlank')}/>
|
||||
<Dropdown id="smtpEncryption" label={t('encryption')} options={smtpEncryptionOptions}/>
|
||||
<CheckBox id="smtpUseAuth" text={t('enableSmtpAuthentication')}/>
|
||||
{ owner.getFormValue('smtpUseAuth') &&
|
||||
<div>
|
||||
<InputField id="smtpUser" label={t('username')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
<InputField id="smtpPassword" type="password" label={t('password')} placeholder={t('usernameEgMyaccount@examplecom')}/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Fieldset>
|
||||
{(zoneMtaType === ZoneMTAType.BUILTIN || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
|
||||
<Fieldset label={t('dkimSigning')}>
|
||||
<Trans i18nKey="ifYouAreUsingZoneMtaThenMailtrainCan"><p>If you are using ZoneMTA then Mailtrain can provide a DKIM key for signing all outgoing messages.</p></Trans>
|
||||
<Trans i18nKey="doNotUseSensitiveKeysHereThePrivateKeyIs"><p className="text-warning">Do not use sensitive keys here. The private key is not encrypted in the database.</p></Trans>
|
||||
{zoneMtaType === ZoneMTAType.WITH_HTTP_CONF &&
|
||||
<InputField id="dkimApiKey" label={t('zoneMtaDkimApiKey')} help={t('secretValueKnownToZoneMtaForRequesting')}/>
|
||||
}
|
||||
<InputField id="dkimDomain" label={t('dkimDomain')} help={t('leaveBlankToUseTheSenderEmailAddress')}/>
|
||||
<InputField id="dkimSelector" label={t('dkimKeySelector')} help={t('signingIsDisabledWithoutAValidSelector')}/>
|
||||
<TextArea id="dkimPrivateKey" className={styles.dkimPrivateKey} label={t('dkimPrivateKey')} placeholder={t('beginsWithBeginRsaPrivateKey')} help={t('signingIsDisabledWithoutAValidPrivateKey')}/>
|
||||
</Fieldset>
|
||||
}
|
||||
<Fieldset label={t('advancedMailerSettings')}>
|
||||
<CheckBox id="logTransactions" text={t('logSmtpTransactions')}/>
|
||||
<CheckBox id="smtpAllowSelfSigned" text={t('allowSelfsignedCertificates')}/>
|
||||
<InputField id="maxConnections" label={t('maxConnections')} placeholder={t('theCountOfMaxConnectionsEg10')} help={t('theCountOfMaximumSimultaneousConnections')}/>
|
||||
<InputField id="smtpMaxMessages" label={t('maxMessages')} placeholder={t('theCountOfMaxMessagesEg100')} help={t('theNumberOfMessagesToSendThroughASingle')}/>
|
||||
<InputField id="throttling" label={t('throttling')} placeholder={t('messagesPerHourEg1000')} help={t('maximumNumberOfMessagesToSendInAnHour')}/>
|
||||
</Fieldset>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
initData: () => ({
|
||||
...getInitGenericSMTP(),
|
||||
zoneMtaType: mailtrainConfig.builtinZoneMTAEnabled ? ZoneMTAType.BUILTIN : ZoneMTAType.REGULAR,
|
||||
dkimApiKey: '',
|
||||
dkimDomain: '',
|
||||
dkimSelector: '',
|
||||
dkimPrivateKey: ''
|
||||
}),
|
||||
afterLoad: data => {
|
||||
afterLoadGenericSMTP(data);
|
||||
data.zoneMtaType = data.mailer_settings.zoneMtaType;
|
||||
data.dkimApiKey = data.mailer_settings.dkimApiKey;
|
||||
data.dkimDomain = data.mailer_settings.dkimDomain;
|
||||
data.dkimSelector = data.mailer_settings.dkimSelector;
|
||||
data.dkimPrivateKey = data.mailer_settings.dkimPrivateKey;
|
||||
},
|
||||
beforeSave: data => {
|
||||
const zoneMtaType = Number.parseInt(data.zoneMtaType);
|
||||
|
||||
beforeSaveGenericSMTP(data, zoneMtaType === ZoneMTAType.BUILTIN);
|
||||
|
||||
data.mailer_settings.zoneMtaType = zoneMtaType;
|
||||
if (zoneMtaType === ZoneMTAType.BUILTIN || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF) {
|
||||
data.mailer_settings.dkimDomain = data.dkimDomain;
|
||||
data.mailer_settings.dkimSelector = data.dkimSelector;
|
||||
data.mailer_settings.dkimPrivateKey = data.dkimPrivateKey;
|
||||
}
|
||||
if (zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) {
|
||||
data.mailer_settings.dkimApiKey = data.dkimApiKey;
|
||||
}
|
||||
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {
|
||||
initFieldsIfMissing(mutState, MailerType.ZONE_MTA);
|
||||
},
|
||||
validate: state => {
|
||||
validateGenericSMTP(state);
|
||||
}
|
||||
};
|
||||
|
||||
mailerTypes[MailerType.AWS_SES] = {
|
||||
typeName: typeNames[MailerType.AWS_SES],
|
||||
getForm: owner =>
|
||||
<div>
|
||||
<Fieldset label={t('mailerSettings')}>
|
||||
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
|
||||
<InputField id="sesKey" label={t('accessKey')} placeholder={t('awsAccessKeyId')}/>
|
||||
<InputField id="sesSecret" label={t('port')} placeholder={t('awsSecretAccessKey')}/>
|
||||
<Dropdown id="sesRegion" label={t('region')} options={sesRegionOptions}/>
|
||||
</Fieldset>
|
||||
<Fieldset label={t('advancedMailerSettings')}>
|
||||
<CheckBox id="logTransactions" text={t('logSmtpTransactions')}/>
|
||||
<InputField id="maxConnections" label={t('maxConnections')} placeholder={t('theCountOfMaxConnectionsEg10')} help={t('theCountOfMaximumSimultaneousConnections')}/>
|
||||
<InputField id="throttling" label={t('throttling')} placeholder={t('messagesPerHourEg1000')} help={t('maximumNumberOfMessagesToSendInAnHour')}/>
|
||||
</Fieldset>
|
||||
</div>,
|
||||
initData: () => ({
|
||||
...getInitCommon(),
|
||||
sesKey: '',
|
||||
sesSecret: '',
|
||||
sesRegion: ''
|
||||
}),
|
||||
afterLoad: data => {
|
||||
afterLoadCommon(data);
|
||||
data.sesKey = data.mailer_settings.key;
|
||||
data.sesSecret = data.mailer_settings.secret;
|
||||
data.sesRegion = data.mailer_settings.region;
|
||||
},
|
||||
beforeSave: data => {
|
||||
beforeSaveCommon(data);
|
||||
data.mailer_settings.key = data.sesKey;
|
||||
data.mailer_settings.secret = data.sesSecret;
|
||||
data.mailer_settings.region = data.sesRegion;
|
||||
clearBeforeSave(data);
|
||||
},
|
||||
afterTypeChange: mutState => {
|
||||
initFieldsIfMissing(mutState, MailerType.AWS_SES);
|
||||
},
|
||||
validate: state => {
|
||||
validateCommon(state);
|
||||
}
|
||||
};
|
||||
|
||||
return mailerTypes;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import React from 'react';
|
|||
import CUD from './CUD';
|
||||
import List from './List';
|
||||
import Share from '../shares/Share';
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
|
||||
|
||||
|
||||
function getMenus(t) {
|
||||
|
@ -15,7 +16,7 @@ function getMenus(t) {
|
|||
panelComponent: List,
|
||||
children: {
|
||||
':sendConfigurationId([0-9]+)': {
|
||||
title: resolved => t('templateName', {name: resolved.sendConfiguration.name}),
|
||||
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.sendConfiguration.name)}),
|
||||
resolve: {
|
||||
sendConfiguration: params => `rest/send-configurations-private/${params.sendConfigurationId}`
|
||||
},
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
textarea.dkimPrivateKey {
|
||||
height: 200px;
|
||||
textarea.dkimPrivateKey {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.overridableCheckbox {
|
||||
margin-top: -8px !important;
|
||||
}
|
|
@ -12,12 +12,12 @@ import {
|
|||
} from '../lib/page'
|
||||
import {
|
||||
Button,
|
||||
ButtonRow,
|
||||
ButtonRow, CheckBox,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormSendMethod,
|
||||
InputField,
|
||||
StaticField,
|
||||
StaticField, TableSelect,
|
||||
TextArea,
|
||||
withForm
|
||||
} from '../lib/form';
|
||||
|
@ -41,6 +41,8 @@ import styles
|
|||
import {getUrl} from "../lib/urls";
|
||||
import {TestSendModalDialog} from "./TestSendModalDialog";
|
||||
import {withComponentMixins} from "../lib/decorator-helpers";
|
||||
import moment
|
||||
from 'moment';
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
|
@ -74,7 +76,8 @@ export default class CUD extends Component {
|
|||
static propTypes = {
|
||||
action: PropTypes.string.isRequired,
|
||||
wizard: PropTypes.string,
|
||||
entity: PropTypes.object
|
||||
entity: PropTypes.object,
|
||||
setPanelInFullScreen: PropTypes.func
|
||||
}
|
||||
|
||||
onTypeChanged(mutStateData, key, oldType, type) {
|
||||
|
@ -83,19 +86,24 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
loadFromEntityMutator(data) {
|
||||
getFormValuesMutator(data) {
|
||||
this.templateTypes[data.type].afterLoad(data);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => this.loadFromEntityMutator(data));
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
name: '',
|
||||
description: '',
|
||||
namespace: mailtrainConfig.user.namespace,
|
||||
type: mailtrainConfig.editors[0],
|
||||
|
||||
fromSourceTemplate: false,
|
||||
sourceTemplate: null,
|
||||
|
||||
text: '',
|
||||
html: '',
|
||||
data: {},
|
||||
|
@ -120,6 +128,12 @@ export default class CUD extends Component {
|
|||
state.setIn(['type', 'error'], t('typeMustBeSelected'));
|
||||
}
|
||||
|
||||
if (state.getIn(['fromSourceTemplate', 'value']) && !state.getIn(['sourceTemplate', 'value'])) {
|
||||
state.setIn(['sourceTemplate', 'error'], t('Source template must not be empty'));
|
||||
} else {
|
||||
state.setIn(['sourceTemplate', 'error'], null);
|
||||
}
|
||||
|
||||
validateNamespace(t, state);
|
||||
|
||||
if (typeKey) {
|
||||
|
@ -127,7 +141,11 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async doSave(stayOnPage) {
|
||||
async save() {
|
||||
await this.submitHandler();
|
||||
}
|
||||
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let exportedData = {};
|
||||
|
@ -148,23 +166,26 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
Object.assign(data, exportedData);
|
||||
this.templateTypes[data.type].beforeSave(data);
|
||||
});
|
||||
|
||||
if (submitResponse) {
|
||||
if (stayOnPage) {
|
||||
await this.getFormValuesFromURL(`rest/templates/${this.props.entity.id}`, data => this.loadFromEntityMutator(data));
|
||||
this.enableForm();
|
||||
this.clearFormStatusMessage();
|
||||
this.setFlashMessage('success', t('templateSaved'));
|
||||
|
||||
} else if (this.props.entity) {
|
||||
this.navigateToWithFlashMessage('/templates', 'success', t('templateSaved'));
|
||||
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/templates', 'success', t('Template updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/templates/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Template updated'));
|
||||
}
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/templates/${submitResponse}/edit`, 'success', t('templateSaved'));
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/templates', 'success', t('Template created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/templates/${submitResult}/edit`, 'success', t('Template created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
@ -172,14 +193,6 @@ export default class CUD extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
await this.doSave(true);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
await this.doSave(false);
|
||||
}
|
||||
|
||||
async extractPlainText() {
|
||||
const typeKey = this.getFormValue('type');
|
||||
const exportedData = await this.templateTypes[typeKey].exportHTMLEditorData(this);
|
||||
|
@ -209,6 +222,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
async setElementInFullscreen(elementInFullscreen) {
|
||||
this.props.setPanelInFullScreen(elementInFullscreen);
|
||||
this.setState({
|
||||
elementInFullscreen
|
||||
});
|
||||
|
@ -252,6 +266,13 @@ export default class CUD extends Component {
|
|||
typeForm = getTypeForm(this, typeKey, isEdit);
|
||||
}
|
||||
|
||||
const templatesColumns = [
|
||||
{ data: 1, title: t('name') },
|
||||
{ data: 2, title: t('description') },
|
||||
{ data: 3, title: t('type'), render: data => this.templateTypes[data].typeName },
|
||||
{ data: 4, title: t('created'), render: data => moment(data).fromNow() },
|
||||
{ data: 5, title: t('namespace') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={this.state.elementInFullscreen ? styles.withElementInFullscreen : ''}>
|
||||
|
@ -278,25 +299,35 @@ export default class CUD extends Component {
|
|||
<InputField id="name" label={t('name')}/>
|
||||
<TextArea id="description" label={t('description')}/>
|
||||
|
||||
{isEdit
|
||||
?
|
||||
<StaticField id="type" className={styles.formDisabled} label={t('type')}>
|
||||
{typeKey && this.templateTypes[typeKey].typeName}
|
||||
</StaticField>
|
||||
:
|
||||
<Dropdown id="type" label={t('type')} options={typeOptions}/>
|
||||
{!isEdit &&
|
||||
<CheckBox id="fromSourceTemplate" label={t('template')} text={t('Clone from an existing template')}/>
|
||||
}
|
||||
|
||||
{typeForm}
|
||||
{this.getFormValue('fromSourceTemplate') ?
|
||||
<TableSelect key="templateSelect" id="sourceTemplate" withHeader dropdown dataUrl='rest/templates-table' columns={templatesColumns} selectionLabelIndex={1} />
|
||||
:
|
||||
<>
|
||||
{isEdit ?
|
||||
<StaticField id="type" className={styles.formDisabled} label={t('type')}>
|
||||
{typeKey && this.templateTypes[typeKey].typeName}
|
||||
</StaticField>
|
||||
:
|
||||
<Dropdown id="type" label={t('type')} options={typeOptions}/>
|
||||
}
|
||||
|
||||
{typeForm}
|
||||
</>
|
||||
}
|
||||
|
||||
<NamespaceSelect/>
|
||||
|
||||
{editForm}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={isEdit ? t('save') : t('saveAndEditTemplate')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
{isEdit && <Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>}
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/${this.props.entity.id}/delete`}/> }
|
||||
{isEdit && <Button className="btn-danger" icon="send" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> }
|
||||
{isEdit && <Button className="btn-success" icon="at" label={t('testSend')} onClickAsync={async () => this.setState({showTestSendModal: true})}/> }
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -141,8 +141,8 @@ export class TestSendModalDialog extends Component {
|
|||
|
||||
return (
|
||||
<ModalDialog hidden={!this.props.visible} title={t('sendTestEmail')} onCloseAsync={() => this.hideModal()} buttons={[
|
||||
{ label: t('send'), className: 'btn-danger', onClickAsync: ::this.performAction },
|
||||
{ label: t('cancel'), className: 'btn-primary', onClickAsync: ::this.hideModal }
|
||||
{ label: t('send'), className: 'btn-primary', onClickAsync: ::this.performAction },
|
||||
{ label: t('cancel'), className: 'btn-danger', onClickAsync: ::this.hideModal }
|
||||
]}>
|
||||
<Form stateOwner={this} format="wide">
|
||||
<TableSelect id="sendConfiguration" format="wide" label={t('sendConfiguration')} withHeader dropdown dataUrl='rest/send-configurations-with-send-permission-table' columns={sendConfigurationsColumns} selectionLabelIndex={1} />
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -66,11 +66,13 @@ export default class CUD extends Component {
|
|||
entity: PropTypes.object
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
this.templateTypes[data.type].afterLoad(data);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
this.templateTypes[data.type].afterLoad(data);
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
|
||||
} else {
|
||||
const wizard = this.props.wizard;
|
||||
|
@ -114,15 +116,7 @@ export default class CUD extends Component {
|
|||
validateNamespace(t, state);
|
||||
}
|
||||
|
||||
async submitAndStay() {
|
||||
await this.formHandleChangedError(async () => await this.doSubmit(true));
|
||||
}
|
||||
|
||||
async submitAndLeave() {
|
||||
await this.formHandleChangedError(async () => await this.doSubmit(false));
|
||||
}
|
||||
|
||||
async doSubmit(stay) {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -137,19 +131,25 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
this.templateTypes[data.type].beforeSave(data);
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
if (stay) {
|
||||
await this.getFormValuesFromURL(`rest/mosaico-templates/${this.props.entity.id}`, data => {
|
||||
this.templateTypes[data.type].afterLoad(data);
|
||||
});
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('mosaicoTemplateSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('Mosaico template updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/mosaico-templates/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('Mosaico template updated'));
|
||||
}
|
||||
} else {
|
||||
this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('mosaicoTemplateSaved'));
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/templates/mosaico', 'success', t('Mosaico template created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/templates/mosaico/${submitResult}/edit`, 'success', t('Mosaico template created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
|
@ -183,7 +183,7 @@ export default class CUD extends Component {
|
|||
|
||||
<Title>{isEdit ? t('editMosaicoTemplate') : t('createMosaicoTemplate')}</Title>
|
||||
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
|
||||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<InputField id="name" label={t('name')}/>
|
||||
<TextArea id="description" label={t('description')}/>
|
||||
<Dropdown id="type" label={t('type')} options={this.typeOptions}/>
|
||||
|
@ -191,19 +191,11 @@ export default class CUD extends Component {
|
|||
|
||||
{form}
|
||||
|
||||
{isEdit ?
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndStay')} onClickAsync={::this.submitAndStay}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('saveAndLeave')}/>
|
||||
{canDelete &&
|
||||
<LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/mosaico/${this.props.entity.id}/delete`}/>
|
||||
}
|
||||
</ButtonRow>
|
||||
:
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
</ButtonRow>
|
||||
}
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/mosaico/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,51 +1,51 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import {ACEEditor} from "../../lib/form";
|
||||
import 'brace/mode/html'
|
||||
import 'brace/mode/xml'
|
||||
|
||||
export function getTemplateTypesOrder() {
|
||||
return [/* 'mjml' , */ 'html'];
|
||||
}
|
||||
|
||||
export function getTemplateTypes(t) {
|
||||
const templateTypes = {};
|
||||
|
||||
function clearBeforeSend(data) {
|
||||
delete data.html;
|
||||
delete data.mjml;
|
||||
}
|
||||
|
||||
templateTypes.html = {
|
||||
typeName: t('html'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="html" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
data.html = data.data.html;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
data.data = {
|
||||
html: data.html
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
};
|
||||
|
||||
templateTypes.mjml = {
|
||||
typeName: t('mjml'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="xml" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
data.mjml = data.data.mjml;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
data.data = {
|
||||
mjml: data.mjml
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
};
|
||||
|
||||
return templateTypes;
|
||||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import {ACEEditor} from "../../lib/form";
|
||||
import 'brace/mode/html'
|
||||
import 'brace/mode/xml'
|
||||
|
||||
export function getTemplateTypesOrder() {
|
||||
return [/* 'mjml' , */ 'html'];
|
||||
}
|
||||
|
||||
export function getTemplateTypes(t) {
|
||||
const templateTypes = {};
|
||||
|
||||
function clearBeforeSend(data) {
|
||||
delete data.html;
|
||||
delete data.mjml;
|
||||
}
|
||||
|
||||
templateTypes.html = {
|
||||
typeName: t('html'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="html" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
data.html = data.data.html;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
data.data = {
|
||||
html: data.html
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
};
|
||||
|
||||
templateTypes.mjml = {
|
||||
typeName: t('mjml'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="xml" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
data.mjml = data.data.mjml;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
data.data = {
|
||||
mjml: data.mjml
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
};
|
||||
|
||||
return templateTypes;
|
||||
}
|
|
@ -8,6 +8,7 @@ import Share from '../shares/Share';
|
|||
import Files from "../lib/files";
|
||||
import MosaicoCUD from './mosaico/CUD';
|
||||
import MosaicoList from './mosaico/List';
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
|
||||
|
||||
|
||||
function getMenus(t) {
|
||||
|
@ -18,7 +19,7 @@ function getMenus(t) {
|
|||
panelComponent: TemplatesList,
|
||||
children: {
|
||||
':templateId([0-9]+)': {
|
||||
title: resolved => t('templateName', {name: resolved.template.name}),
|
||||
title: resolved => t('templateName', {name: ellipsizeBreadcrumbLabel(resolved.template.name)}),
|
||||
resolve: {
|
||||
template: params => `rest/templates/${params.templateId}`
|
||||
},
|
||||
|
@ -28,7 +29,7 @@ function getMenus(t) {
|
|||
title: t('edit'),
|
||||
link: params => `/templates/${params.templateId}/edit`,
|
||||
visible: resolved => resolved.template.permissions.includes('edit'),
|
||||
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} />
|
||||
panelRender: props => <TemplatesCUD action={props.match.params.action} entity={props.resolved.template} setPanelInFullScreen={props.setPanelInFullScreen} />
|
||||
},
|
||||
files: {
|
||||
title: t('files'),
|
||||
|
@ -54,7 +55,7 @@ function getMenus(t) {
|
|||
panelComponent: MosaicoList,
|
||||
children: {
|
||||
':mosaiceTemplateId([0-9]+)': {
|
||||
title: resolved => t('mosaicoTemplateName', {name: resolved.mosaicoTemplate.name}),
|
||||
title: resolved => t('mosaicoTemplateName', {name: ellipsizeBreadcrumbLabel(resolved.mosaicoTemplate.name)}),
|
||||
resolve: {
|
||||
mosaicoTemplate: params => `rest/mosaico-templates/${params.mosaiceTemplateId}`
|
||||
},
|
||||
|
|
|
@ -62,12 +62,14 @@ export default class CUD extends Component {
|
|||
entity: PropTypes.object
|
||||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
data.password = '';
|
||||
data.password2 = '';
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.password = '';
|
||||
data.password2 = '';
|
||||
});
|
||||
this.getFormValuesFromEntity(this.props.entity, ::this.getFormValuesMutator);
|
||||
} else {
|
||||
this.populateFormValues({
|
||||
username: '',
|
||||
|
@ -156,7 +158,7 @@ export default class CUD extends Component {
|
|||
validateNamespace(t, state);
|
||||
}
|
||||
|
||||
async submitHandler() {
|
||||
async submitHandler(submitAndLeave) {
|
||||
const t = this.props.t;
|
||||
|
||||
let sendMethod, url;
|
||||
|
@ -172,12 +174,26 @@ export default class CUD extends Component {
|
|||
this.disableForm();
|
||||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
delete data.password2;
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
this.navigateToWithFlashMessage('/users', 'success', t('userSaved'));
|
||||
if (submitResult) {
|
||||
if (this.props.entity) {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/users', 'success', t('User updated'));
|
||||
} else {
|
||||
await this.getFormValuesFromURL(`rest/users/${this.props.entity.id}`, ::this.getFormValuesMutator);
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('success', t('User updated'));
|
||||
}
|
||||
} else {
|
||||
if (submitAndLeave) {
|
||||
this.navigateToWithFlashMessage('/users', 'success', t('User created'));
|
||||
} else {
|
||||
this.navigateToWithFlashMessage(`/users/${submitResult}/edit`, 'success', t('User created'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.enableForm();
|
||||
this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
|
||||
|
@ -248,7 +264,8 @@ export default class CUD extends Component {
|
|||
<NamespaceSelect/>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('deleteUser')} to={`/users/${this.props.entity.id}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
|
|
|
@ -8,6 +8,7 @@ import List
|
|||
from './List';
|
||||
import UserShares
|
||||
from '../shares/UserShares';
|
||||
import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
|
||||
|
||||
function getMenus(t) {
|
||||
return {
|
||||
|
@ -17,7 +18,7 @@ function getMenus(t) {
|
|||
panelComponent: List,
|
||||
children: {
|
||||
':userId([0-9]+)': {
|
||||
title: resolved => t('userName-1', {name: resolved.user.name}),
|
||||
title: resolved => t('userName-1', {name: ellipsizeBreadcrumbLabel(resolved.user.name)}),
|
||||
resolve: {
|
||||
user: params => `rest/users/${params.userId}`
|
||||
},
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
The file icons-rtl.gif is assumed by skin-common.less. However skin-bootstrap does not come with icons.
|
||||
The file icons-rtl.gif is assumed by skin-common.less. However skin-bootstrap does not come with icons.
|
||||
To avoid errors with webpack an empty file has been provided.
|
4
client/static/mosaico/uploads/.gitignore
vendored
4
client/static/mosaico/uploads/.gitignore
vendored
|
@ -1,3 +1,3 @@
|
|||
*
|
||||
!.gitignore
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
|
@ -1,7 +1,7 @@
|
|||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('./noto-sans-400-normal.eot');
|
||||
src: local('Noto Sans'), local('NotoSans'), url('./noto-sans-400-normal.eot#iefix') format('embedded-opentype'), url('./noto-sans-400-normal.woff') format('woff'), url('./noto-sans-400-normal.ttf') format('truetype');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('./noto-sans-400-normal.eot');
|
||||
src: local('Noto Sans'), local('NotoSans'), url('./noto-sans-400-normal.eot#iefix') format('embedded-opentype'), url('./noto-sans-400-normal.woff') format('woff'), url('./noto-sans-400-normal.ttf') format('truetype');
|
||||
}
|
||||
|
|
41
docker-compose-local.yml
Normal file
41
docker-compose-local.yml
Normal file
|
@ -0,0 +1,41 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.4
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=mailtrain
|
||||
- MYSQL_DATABASE=mailtrain
|
||||
- MYSQL_USER=mailtrain
|
||||
- MYSQL_PASSWORD=mailtrain
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: redis:5
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
mongo:
|
||||
image: mongo:4-xenial
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
mailtrain:
|
||||
build: .
|
||||
command: ${MAILTRAIN_SETTINGS}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3003:3003"
|
||||
- "3004:3004"
|
||||
volumes:
|
||||
- mailtrain-files:/app/server/files
|
||||
- mailtrain-reports:/app/protected/reports
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
redis-data:
|
||||
mongo-data:
|
||||
mailtrain-files:
|
||||
mailtrain-reports:
|
||||
|
|
@ -22,7 +22,7 @@ services:
|
|||
- mongo-data:/data/db
|
||||
|
||||
mailtrain:
|
||||
build: .
|
||||
image: mailtrain/mailtrain:latest
|
||||
command: ${MAILTRAIN_SETTINGS}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
|
|
@ -1,141 +1,141 @@
|
|||
## Access Control
|
||||
|
||||
This document describes the key features and concepts of the current state of
|
||||
access control in Mailtrain.
|
||||
|
||||
The current state provides user management and granular access control to reports
|
||||
and report templates. The user management supports both local authentication and
|
||||
LDAP-based authentication.
|
||||
|
||||
The access control has two abstractions levels: a high-level intended to be used through web UI,
|
||||
and low-level, intended to be configured once through the Mailtrain config file. The high-level
|
||||
layer serves for providing access to variuous resources, while the low-level layer is meant
|
||||
to define the access roles in Mailtrain to reflect an organisational or process hierarchy.
|
||||
|
||||
### High-level access management (through web UI)
|
||||
|
||||
On the high abstraction level, which is accessible to users via the web-based UI, Mailtrain
|
||||
recognizes different entities (reports, report templates, etc.) and user roles that regulate
|
||||
access to these entities (e.g. role "reporter" that allows viewing a report but prevents editing
|
||||
or deleting it). Access to entities is provided through so called "shares". A share is essentially
|
||||
a triple: entity - role - user.
|
||||
|
||||
Mailtrain further features hierarchical namespaces. Every entity has to reside in a namespace
|
||||
(in reality, the namespace itself is an entity to which access can be given).
|
||||
|
||||
While sharing an entity with a user gives the user access to the particular entity (in the
|
||||
scope of the role), sharing a namespace amounts to giving access to all entities within
|
||||
the namespace and transitively in all child namespaces. The role that regulates the access to the
|
||||
particular namespaces further determines the access to all different entity types that can
|
||||
reside in the namespace.
|
||||
|
||||
To simplify the management of permissions, every user is associated with one global role and
|
||||
a namespace. The global role regulates access to global resources and operations (i.e. those
|
||||
things that are not associated with any namespace). An example of such a global operation is
|
||||
rebuilding the permission cache. Further, the global role determines a default share of the
|
||||
root namespace and the namespace of the user. For example, an administrator's global role may
|
||||
specify that a user get administrator's role in the root namespace, which effectively gives
|
||||
him/her access to everything.
|
||||
|
||||
Mailtrain resets these default shares at start and also whenever permission cache is rebuilt
|
||||
(essentially every time user, namespace or some entity is created or when share or user's
|
||||
role is assigned). This effectively prevents deleting or overriding the default shares that
|
||||
the user has through the global role.
|
||||
|
||||
|
||||
### Low-level access management (through config file)
|
||||
|
||||
Internally, Mailtrain relies on fine-grained permissions, which are triplets:
|
||||
user - operation - entity (e.g. user id 1 - view - report id 2). These permissions are stored
|
||||
in a permission cache (in DB) and automatically generated at startup and whenever the permissions
|
||||
could have changed.
|
||||
|
||||
Mailtrain's config file defines the roles (available in the high-level access management) and
|
||||
specifies the mapping of roles to operations.
|
||||
|
||||
The roles are potentially different for each entity type/scope (currently global, namespace, report,
|
||||
report template). Each role defines the permitted operations for the given entity type/scope.
|
||||
A namespace role further defines allowed operations for entity types within and under the
|
||||
namespace.
|
||||
|
||||
The following defines the role master for scope "global". This effectively means that in
|
||||
"Create/Edit User" form, the user can be given role "Master".
|
||||
The role gives the permission to rebuild the permission cache.
|
||||
|
||||
Further, it specifies that the
|
||||
holder of the role will automatically be given access (share) to the root namespace in the
|
||||
namespace role "master" (specified by ```rootNamespaceRole="master"```). This access to the root namespace is given irrespective of the namespace
|
||||
in which the user is created. This highlight the dual purpose of namespaces: a) they group
|
||||
entities w.r.t. access management, b) they allow categorizing entities and users in a hierarchy
|
||||
to potentially reflect the organisational or process hierarchy. The latter is especially useful for
|
||||
more enterprise applications where a single installation of Mailtrain serves a number of rather
|
||||
independent groups.
|
||||
|
||||
The global role defined below is also an admin role (denoted by the ```admin=true```), which means that user id 1 will always be reset to this role.
|
||||
This serves as a kind of bootstrap that makes sure that there is always a user that can be
|
||||
used to give access to other users.
|
||||
```
|
||||
[roles.global.master]
|
||||
name="Master"
|
||||
admin=true
|
||||
description="All permissions"
|
||||
permissions=["rebuildPermissions"]
|
||||
rootNamespaceRole="master"
|
||||
```
|
||||
|
||||
Another example for a global role is the following. This one is intended for regular users.
|
||||
As such, it does not automatically give access to everything. Rather, it gives limited access
|
||||
to entities under the namespace in which the user has been created. This is specified by the
|
||||
```ownNamespaceRole="editor"```
|
||||
```
|
||||
[roles.global.editor]
|
||||
name="Editor"
|
||||
description="Anything under own namespace except operations related to sending and doing reports"
|
||||
permissions=[]
|
||||
ownNamespaceRole="editor"
|
||||
```
|
||||
|
||||
The roles for entities are defined in a similar fashion. The example below shows the definition
|
||||
of the role "master" for "report" entities. It lists the operations that a user
|
||||
that has "master" access to a particular report can do with the report. Note that to get the
|
||||
"master" access to a particular report through this role, the report would either have to be shared with the user
|
||||
with role "master".
|
||||
```
|
||||
[roles.report.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
|
||||
```
|
||||
|
||||
The same for the restricted role "editor" can look as follows.
|
||||
```
|
||||
[roles.report.editor]
|
||||
name="Editor"
|
||||
description="Anything under own namespace except operations related to sending and doing reports"
|
||||
permissions=["view", "viewContent", "viewOutput"]
|
||||
```
|
||||
|
||||
The following defines the role "master" for "namespace" entities. Similarly to the example above,
|
||||
it lists operations that relate to a namespace. In particular all "create" operations pertain
|
||||
to a namespace rathen than to an entity, which at the time of creation does not exist yet.
|
||||
Additionally, the namespace roles define permissions to all entity types under the namespace
|
||||
(including child namespaces).
|
||||
```
|
||||
[roles.namespace.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"]
|
||||
|
||||
[roles.namespace.master.children]
|
||||
reportTemplate=["view", "edit", "delete", "share", "execute"]
|
||||
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
|
||||
namespace=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"]
|
||||
```
|
||||
|
||||
And the same for the more restricted role "editor".
|
||||
```
|
||||
[roles.namespace.editor.children]
|
||||
reportTemplate=[]
|
||||
report=["view", "viewContent", "viewOutput"]
|
||||
namespace=["view", "edit", "delete"]
|
||||
## Access Control
|
||||
|
||||
This document describes the key features and concepts of the current state of
|
||||
access control in Mailtrain.
|
||||
|
||||
The current state provides user management and granular access control to reports
|
||||
and report templates. The user management supports both local authentication and
|
||||
LDAP-based authentication.
|
||||
|
||||
The access control has two abstractions levels: a high-level intended to be used through web UI,
|
||||
and low-level, intended to be configured once through the Mailtrain config file. The high-level
|
||||
layer serves for providing access to variuous resources, while the low-level layer is meant
|
||||
to define the access roles in Mailtrain to reflect an organisational or process hierarchy.
|
||||
|
||||
### High-level access management (through web UI)
|
||||
|
||||
On the high abstraction level, which is accessible to users via the web-based UI, Mailtrain
|
||||
recognizes different entities (reports, report templates, etc.) and user roles that regulate
|
||||
access to these entities (e.g. role "reporter" that allows viewing a report but prevents editing
|
||||
or deleting it). Access to entities is provided through so called "shares". A share is essentially
|
||||
a triple: entity - role - user.
|
||||
|
||||
Mailtrain further features hierarchical namespaces. Every entity has to reside in a namespace
|
||||
(in reality, the namespace itself is an entity to which access can be given).
|
||||
|
||||
While sharing an entity with a user gives the user access to the particular entity (in the
|
||||
scope of the role), sharing a namespace amounts to giving access to all entities within
|
||||
the namespace and transitively in all child namespaces. The role that regulates the access to the
|
||||
particular namespaces further determines the access to all different entity types that can
|
||||
reside in the namespace.
|
||||
|
||||
To simplify the management of permissions, every user is associated with one global role and
|
||||
a namespace. The global role regulates access to global resources and operations (i.e. those
|
||||
things that are not associated with any namespace). An example of such a global operation is
|
||||
rebuilding the permission cache. Further, the global role determines a default share of the
|
||||
root namespace and the namespace of the user. For example, an administrator's global role may
|
||||
specify that a user get administrator's role in the root namespace, which effectively gives
|
||||
him/her access to everything.
|
||||
|
||||
Mailtrain resets these default shares at start and also whenever permission cache is rebuilt
|
||||
(essentially every time user, namespace or some entity is created or when share or user's
|
||||
role is assigned). This effectively prevents deleting or overriding the default shares that
|
||||
the user has through the global role.
|
||||
|
||||
|
||||
### Low-level access management (through config file)
|
||||
|
||||
Internally, Mailtrain relies on fine-grained permissions, which are triplets:
|
||||
user - operation - entity (e.g. user id 1 - view - report id 2). These permissions are stored
|
||||
in a permission cache (in DB) and automatically generated at startup and whenever the permissions
|
||||
could have changed.
|
||||
|
||||
Mailtrain's config file defines the roles (available in the high-level access management) and
|
||||
specifies the mapping of roles to operations.
|
||||
|
||||
The roles are potentially different for each entity type/scope (currently global, namespace, report,
|
||||
report template). Each role defines the permitted operations for the given entity type/scope.
|
||||
A namespace role further defines allowed operations for entity types within and under the
|
||||
namespace.
|
||||
|
||||
The following defines the role master for scope "global". This effectively means that in
|
||||
"Create/Edit User" form, the user can be given role "Master".
|
||||
The role gives the permission to rebuild the permission cache.
|
||||
|
||||
Further, it specifies that the
|
||||
holder of the role will automatically be given access (share) to the root namespace in the
|
||||
namespace role "master" (specified by ```rootNamespaceRole="master"```). This access to the root namespace is given irrespective of the namespace
|
||||
in which the user is created. This highlight the dual purpose of namespaces: a) they group
|
||||
entities w.r.t. access management, b) they allow categorizing entities and users in a hierarchy
|
||||
to potentially reflect the organisational or process hierarchy. The latter is especially useful for
|
||||
more enterprise applications where a single installation of Mailtrain serves a number of rather
|
||||
independent groups.
|
||||
|
||||
The global role defined below is also an admin role (denoted by the ```admin=true```), which means that user id 1 will always be reset to this role.
|
||||
This serves as a kind of bootstrap that makes sure that there is always a user that can be
|
||||
used to give access to other users.
|
||||
```
|
||||
[roles.global.master]
|
||||
name="Master"
|
||||
admin=true
|
||||
description="All permissions"
|
||||
permissions=["rebuildPermissions"]
|
||||
rootNamespaceRole="master"
|
||||
```
|
||||
|
||||
Another example for a global role is the following. This one is intended for regular users.
|
||||
As such, it does not automatically give access to everything. Rather, it gives limited access
|
||||
to entities under the namespace in which the user has been created. This is specified by the
|
||||
```ownNamespaceRole="editor"```
|
||||
```
|
||||
[roles.global.editor]
|
||||
name="Editor"
|
||||
description="Anything under own namespace except operations related to sending and doing reports"
|
||||
permissions=[]
|
||||
ownNamespaceRole="editor"
|
||||
```
|
||||
|
||||
The roles for entities are defined in a similar fashion. The example below shows the definition
|
||||
of the role "master" for "report" entities. It lists the operations that a user
|
||||
that has "master" access to a particular report can do with the report. Note that to get the
|
||||
"master" access to a particular report through this role, the report would either have to be shared with the user
|
||||
with role "master".
|
||||
```
|
||||
[roles.report.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
|
||||
```
|
||||
|
||||
The same for the restricted role "editor" can look as follows.
|
||||
```
|
||||
[roles.report.editor]
|
||||
name="Editor"
|
||||
description="Anything under own namespace except operations related to sending and doing reports"
|
||||
permissions=["view", "viewContent", "viewOutput"]
|
||||
```
|
||||
|
||||
The following defines the role "master" for "namespace" entities. Similarly to the example above,
|
||||
it lists operations that relate to a namespace. In particular all "create" operations pertain
|
||||
to a namespace rathen than to an entity, which at the time of creation does not exist yet.
|
||||
Additionally, the namespace roles define permissions to all entity types under the namespace
|
||||
(including child namespaces).
|
||||
```
|
||||
[roles.namespace.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"]
|
||||
|
||||
[roles.namespace.master.children]
|
||||
reportTemplate=["view", "edit", "delete", "share", "execute"]
|
||||
report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"]
|
||||
namespace=["view", "edit", "delete", "share", "createNamespace", "createReportTemplate", "createReport", "manageUsers"]
|
||||
```
|
||||
|
||||
And the same for the more restricted role "editor".
|
||||
```
|
||||
[roles.namespace.editor.children]
|
||||
reportTemplate=[]
|
||||
report=["view", "viewContent", "viewOutput"]
|
||||
namespace=["view", "edit", "delete"]
|
||||
```
|
|
@ -10,11 +10,11 @@
|
|||
"thisApiCallEitherInsertsANewSubscription": "This API call either inserts a new subscription or updates existing. Fields not included are left as is, so if you update only LAST_NAME value, then FIRST_NAME is kept untouched for an existing subscription.",
|
||||
"arguments": "arguments",
|
||||
"yourPersonalAccessToken": "your personal access token",
|
||||
"subscribersEmailAddress": "subscriber\\'s email address",
|
||||
"subscribersEmailAddress": "subscriber's email address",
|
||||
"required": "required",
|
||||
"subscribersFirstName": "subscriber\\'s first name",
|
||||
"subscribersLastName": "subscriber\\'s last name",
|
||||
"subscribersTimezoneEgEuropeTallinnPstOr": "subscriber\\'s timezone (eg. \"Europe/Tallinn\", \"PST\" or \"UTC\"). If not set defaults to \"UTC\"",
|
||||
"subscribersFirstName": "subscriber's first name",
|
||||
"subscribersLastName": "subscriber's last name",
|
||||
"subscribersTimezoneEgEuropeTallinnPstOr": "subscriber's timezone (eg. \"Europe/Tallinn\", \"PST\" or \"UTC\"). If not set defaults to \"UTC\"",
|
||||
"customFieldValueUseYesnoForOptionGroup": "custom field value. Use yes/no for option group values (checkboxes, radios, drop downs)",
|
||||
"additionalPostArguments": "Additional POST arguments",
|
||||
"setToYesIfYouWantToMakeSureTheEmailIs": "set to \"yes\" if you want to make sure the email is marked as subscribed even if it was previously marked as unsubscribed. If the email was already unsubscribed/blocked then subscription status is not changed",
|
||||
|
@ -28,7 +28,7 @@
|
|||
"thisApiCallCreatesANewCustomFieldForA": "This API call creates a new custom field for a list.",
|
||||
"fieldName": "field name",
|
||||
"oneOfTheFollowingTypes": "one of the following types:",
|
||||
"ifTheTypeIsOptionThenYouAlsoNeedTo": "If the type is \\'option\\' then you also need to specify the parent element ID",
|
||||
"ifTheTypeIsOptionThenYouAlsoNeedTo": "If the type is 'option' then you also need to specify the parent element ID",
|
||||
"templateForTheGroupElementIfNotSetThen": "Template for the group element. If not set, then values of the elements are joined with commas",
|
||||
"ifNotVisibleThenTheSubscriberCanNotView": "if not visible then the subscriber can not view or modify this value at the profile page",
|
||||
"getListOfBlacklistedEmails": "Get list of blacklisted emails",
|
||||
|
@ -117,6 +117,8 @@
|
|||
"addToBlacklist": "Add to Blacklist",
|
||||
"blacklistedEmails": "Blacklisted Emails",
|
||||
"createRegularCampaign": "Create Regular Campaign",
|
||||
"sendSettings": "Send settings",
|
||||
"workWithCampaignNamespace": "Work with campaign's namespace",
|
||||
"createRssCampaign": "Create RSS Campaign",
|
||||
"createTriggeredCampaign": "Create Triggered Campaign",
|
||||
"editRegularCampaign": "Edit Regular Campaign",
|
||||
|
@ -146,6 +148,7 @@
|
|||
"description": "Description",
|
||||
"namespace": "Namespace",
|
||||
"namespace_plural": "Namespaces",
|
||||
"namespaceFiltering": "Namespace filtering",
|
||||
"remove": "Remove",
|
||||
"insertNewEntryBeforeThisOne": "Insert new entry before this one",
|
||||
"moveUp": "Move up",
|
||||
|
@ -198,6 +201,7 @@
|
|||
"triggers": "Triggers",
|
||||
"share": "Share",
|
||||
"createCampaign": "Create Campaign",
|
||||
"namespaceFilter": "Namespace filtering",
|
||||
"regular": "Regular",
|
||||
"rss": "RSS",
|
||||
"triggered": "Triggered",
|
||||
|
@ -244,7 +248,9 @@
|
|||
"timeMustNotBeEmpty": "Time must not be empty",
|
||||
"timeIsInvalid": "Time is invalid",
|
||||
"confirmReset": "Confirm reset",
|
||||
"confirmLaunch": "Confirm launch",
|
||||
"doYouWantToResetTheCampaign?All": "Do you want to reset the campaign? All statistics and the track of delivered messages will be lost.",
|
||||
"doYouWantToLaunchTheCampaign?All": "Do you want to launch the campaign?",
|
||||
"no": "No",
|
||||
"yes": "Yes",
|
||||
"subscribers-1": "subscribers",
|
||||
|
@ -332,7 +338,7 @@
|
|||
"size": "Size",
|
||||
"download": "Download",
|
||||
"confirmFileDeletion": "Confirm file deletion",
|
||||
"filesareYouSureToDeleteFile": "files:areYouSureToDeleteFile",
|
||||
"filesareYouSureToDeleteFile": "Are you sure you want to delete the file?",
|
||||
"dropCountFile": "Drop {{count}} file",
|
||||
"dropCountFile_plural": "Drop {{count}} files",
|
||||
"dropFilesHere": "Drop files here",
|
||||
|
@ -353,11 +359,11 @@
|
|||
"andMore": "... and more",
|
||||
"confirmDeletion": "Confirm Deletion",
|
||||
"areYouSureYouWantToDeleteName?": "Are you sure you want to delete \"{{name}}\"?",
|
||||
"namespacemustBeSelected": "namespace.mustBeSelected",
|
||||
"namespacemustBeSelected": "Namespace must be selected",
|
||||
"mjml": "MJML",
|
||||
"html": "HTML",
|
||||
"countEntriesSelected": "{{ count }} entries selected.",
|
||||
"loading-1": "Loading...",
|
||||
"loading-1": "Loading ...",
|
||||
"customFormMustBeSelected": "Custom form must be selected",
|
||||
"listSaved": "List saved",
|
||||
"onestepIeNoEmailWithConfirmationLink": "One-step (i.e. no email with confirmation link)",
|
||||
|
@ -485,7 +491,7 @@
|
|||
"formsPreview": "Forms Preview",
|
||||
"listToPreviewOn": "List To Preview On",
|
||||
"selectListWhoseFieldsWillBeUsedToPreview": "Select list whose fields will be used to preview the forms.",
|
||||
"noteTheseLinksAreSolelyForAQuickPreview": "Note: These links are solely for a quick preview. To get the address of the subscription form, go to the list\\'s subscribers and click on \"Subscription Form\".",
|
||||
"noteTheseLinksAreSolelyForAQuickPreview": "Note: These links are solely for a quick preview. To get the address of the subscription form, go to the list's subscribers and click on \"Subscription Form\".",
|
||||
"formPreview": "Form preview:",
|
||||
"templates": "Templates",
|
||||
"customFormsUseMjmlForFormattingSeeThe": "Custom forms use MJML for formatting. See the MJML documentation <1>here</1>",
|
||||
|
@ -768,7 +774,7 @@
|
|||
"theVerpServerHostnameEgBouncesexamplecom": "The VERP server hostname, eg. bounces.example.com",
|
||||
"verpBounceHandlingServerHostnameThis": "VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server",
|
||||
"disableSenderHeader": "Disable sender header",
|
||||
"withDmarcTheReturnPathAndFromAddressMust": "With DMARC, the Return-Path and From address must match the same domain. By default we get around this by using the VERP address in the Sender header, with the side effect that some email clients diplay an ugly on behalf of message. You can safely disable this Sender header if you\\'re not using DMARC or your VERP hostname is in the same domain as the From address.",
|
||||
"withDmarcTheReturnPathAndFromAddressMust": "With DMARC, the Return-Path and From address must match the same domain. By default we get around this by using the VERP address in the Sender header, with the side effect that some email clients diplay an ugly on behalf of message. You can safely disable this Sender header if you're not using DMARC or your VERP hostname is in the same domain as the From address.",
|
||||
"mailtrainIsAbleToUseVerpBasedRoutingTo": "<0>Mailtrain is able to use VERP based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.</0>",
|
||||
"toGetVerpWorkingYouNeedToSetUpADnsMx": "<0>To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to someuser@verp-hostname then the email should end up to this server.</0>",
|
||||
"verpUsuallyOnlyWorksIfYouAreUsingYourOwn": "<0>VERP usually only works if you are using your own SMTP server. Regular relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.</0>",
|
||||
|
@ -786,8 +792,8 @@
|
|||
"uswest2": "US-WEST-2",
|
||||
"euwest1": "EU-WEST-1",
|
||||
"builtinZoneMta": "Built-in ZoneMTA",
|
||||
"dynamicConfigurationOfDkimKeysViaZoneMt": "Dynamic configuration of DKIM keys via ZoneMTA\\'s Mailtrain plugin (use this option for builtin ZoneMTA)",
|
||||
"dynamicConfigurationOfDkimKeysViaZoneMt-1": "Dynamic configuration of DKIM keys via ZoneMTA\\'s HTTP config plugin",
|
||||
"dynamicConfigurationOfDkimKeysViaZoneMt": "Dynamic configuration of DKIM keys via ZoneMTA's Mailtrain plugin",
|
||||
"dynamicConfigurationOfDkimKeysViaZoneMt-1": "Dynamic configuration of DKIM keys via ZoneMTA's HTTP config plugin",
|
||||
"noDynamicConfigurationOfDkimKeys": "No dynamic configuration of DKIM keys",
|
||||
"mailerSettings": "Mailer Settings",
|
||||
"mailerType": "Mailer type",
|
||||
|
@ -845,7 +851,7 @@
|
|||
"passphraseForTheKeyIfSet": "Passphrase for the key if set",
|
||||
"onlyFillThisIfYourPrivateKeyIsEncrypted": "Only fill this if your private key is encrypted with a passphrase",
|
||||
"gpgPrivateKey": "GPG private key",
|
||||
"beginsWithBeginPgpPrivateKeyBlock": "Begins with \\'-----BEGIN PGP PRIVATE KEY BLOCK-----\\'",
|
||||
"beginsWithBeginPgpPrivateKeyBlock": "Begins with '-----BEGIN PGP PRIVATE KEY BLOCK-----'",
|
||||
"thisValueIsOptionalIfYouDoNotProvideA": "This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.",
|
||||
"onlyMessagesThatAreEncryptedCanBeSigned": "<0>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</0>",
|
||||
"doNotUseSensitiveKeysHereThePrivateKey": "<0>Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</0>",
|
||||
|
@ -920,7 +926,7 @@
|
|||
"deleteUser": "Delete User",
|
||||
"userName-1": "User \"{{name}}\"",
|
||||
"shares": "Shares",
|
||||
"subscriptionconfirmed": "subscription.confirmed",
|
||||
"subscriptionconfirmed": "Subscription Confirmed",
|
||||
"listEmailAddressAlreadyRegistered": "{{list}}: Email Address Already Registered",
|
||||
"listPleaseConfirmEmailChangeIn": "{{list}}: Please Confirm Email Change in Subscription",
|
||||
"pleaseConfirmSubscription": "Please Confirm Subscription",
|
||||
|
@ -929,7 +935,7 @@
|
|||
"invalidEmailAddressEmailMxRecordNotFound": "Invalid email address \"{{email}}\": MX record not found for domain",
|
||||
"invalidEmailAddressEmailAddressDomainNot": "Invalid email address \"{{email}}\": Address domain not found",
|
||||
"invalidEmailAddressEmailAddressDomain": "Invalid email address \"{{email}}\": Address domain name is required",
|
||||
"invalidEmailGeneric": "invalidEmailGeneric",
|
||||
"invalidEmailGeneric": "Invalid email address \"{{email}}\"",
|
||||
"mailerPasswordChangeRequest": "Mailer password change request",
|
||||
"mailtrain": "Mailtrain",
|
||||
"emailAddressChanged": "Email address changed",
|
||||
|
|
969
locales/es-ES/common.json
Normal file
969
locales/es-ES/common.json
Normal file
|
@ -0,0 +1,969 @@
|
|||
{
|
||||
"welcomeToMailtrain": "Bienvenido a Mailtrain...",
|
||||
"personalAccessToken": "Token de acceso personal",
|
||||
"accessTokenNotYetGenerated": "El token de acceso aun no se ha generado",
|
||||
"api": "API",
|
||||
"resetAccessToken": "Reiniciar token de acceso",
|
||||
"generateAccessToken": "Generar token de acceso",
|
||||
"notesAboutTheApi": "Notas sobre la API",
|
||||
"addSubscription": "Añadir suscripcion",
|
||||
"thisApiCallEitherInsertsANewSubscription": "Esta llamada a la API inserta una nueva suscripción o actualiza las existentes. Los campos no incluidos se dejan como están, por lo que si actualiza solo el valor de LAST_NAME, FIRST_NAME se mantiene intacto para una suscripción existente.",
|
||||
"arguments": "argumentos",
|
||||
"yourPersonalAccessToken": "tu token de acceso personal",
|
||||
"subscribersEmailAddress": "email del suscriptor",
|
||||
"required": "requerido",
|
||||
"subscribersFirstName": "nombre del suscriptor",
|
||||
"subscribersLastName": "apellidos del suscriptor",
|
||||
"subscribersTimezoneEgEuropeTallinnPstOr": "zona horaria del suscriptor (eg. \"Europe/Tallinn\", \"PST\" or \"UTC\"). Si no, asigna por defecto a \"UTC\"",
|
||||
"customFieldValueUseYesnoForOptionGroup": "valor de campo personalizado. Usa si/no para opciones grupales (checkboxes, radios, drop downs)",
|
||||
"additionalPostArguments": "argumentos POST adicionales",
|
||||
"setToYesIfYouWantToMakeSureTheEmailIs": "set to \"yes\" if you want to make sure the email is marked as subscribed even if it was previously marked as unsubscribed. If the email was already unsubscribed/blocked then subscription status is not changed",
|
||||
"setToYesIfYouWantToSendConfirmationEmail": "set to \"yes\" if you want to send confirmation email to the subscriber before actually marking as subscribed",
|
||||
"example": "Ejemplo",
|
||||
"removeSubscription": "Eliminar suscripción",
|
||||
"thisApiCallMarksASubscriptionAs": "Esta llamada a la API marca una suscricion como desuscrita",
|
||||
"deleteSubscription": "Borrar suscripción",
|
||||
"thisApiCallDeletesASubscription": "Esta llamada a la API borra una suscripción",
|
||||
"addNewCustomField": "Añadir un campo personalizado",
|
||||
"thisApiCallCreatesANewCustomFieldForA": "Esta llamada a la API crea un nuevo campo personalizado para una lista.",
|
||||
"fieldName": "Nombre del campo",
|
||||
"oneOfTheFollowingTypes": "uno de los siguientes tipos:",
|
||||
"ifTheTypeIsOptionThenYouAlsoNeedTo": "Si el tipo es 'opción' entonces también necesitas especificar el ID del elemento padre",
|
||||
"templateForTheGroupElementIfNotSetThen": "Template for the group element. If not set, then values of the elements are joined with commas",
|
||||
"ifNotVisibleThenTheSubscriberCanNotView": "if not visible then the subscriber can not view or modify this value at the profile page",
|
||||
"getListOfBlacklistedEmails": "Get list of blacklisted emails",
|
||||
"thisApiCallGetListOfBlacklistedEmails": "This API call get list of blacklisted emails.",
|
||||
"startPosition": "Empezar posición",
|
||||
"optionalDefault0": "optional, default 0",
|
||||
"limitEmailsCountInResponse": "limit emails count in response",
|
||||
"optionalDefault10000": "opcional, por defecto 10000",
|
||||
"filterByPartOfEmail": "filter by part of email",
|
||||
"optionalDefault": "opcional, por defecto \"\"",
|
||||
"addEmailToBlacklist": "Add email to blacklist",
|
||||
"thisApiCallEitherAddEmailsToBlacklist": "This API call either add emails to blacklist",
|
||||
"emailAddress": "dirección de correo",
|
||||
"deleteEmailFromBlacklist": "Borrar email de la lista negra",
|
||||
"thisApiCallEitherDeleteEmailsFrom": "This API call either delete emails from blacklist",
|
||||
"getTheListsAUserHasSubscribedTo": "Get the lists a user has subscribed to",
|
||||
"retrieveTheListsThatTheUserWithEmailHas": "Retrieve the lists that the user with :email has subscribed to.",
|
||||
"apiResponseIsAJsonStructureWithErrorAnd": "API response is a JSON structure with <1>error</1> and <3>data</3> properties. If the response <5>error</5> has a value set then the request failed.",
|
||||
"youNeedToDefineProperContentTypeWhen": "You need to define proper <1>Content-Type</1> when making a request. You can either use <3>application/x-www-form-urlencoded</3> for normal form data or <5>application/json</5> for a JSON payload. Using <7>multipart/form-data</7> is not supported.",
|
||||
"emailMustNotBeEmpty": "El email no debe estar vacío.",
|
||||
"invalidEmailAddress": "Email inválido.",
|
||||
"theEmailIsAlreadyAssociatedWithAnother": "El email ya está asociado a otro usuario del sistema.",
|
||||
"validationIsInProgress": "Validación en progreso...",
|
||||
"fullNameMustNotBeEmpty": "El nombre completo no debe estar vacío.",
|
||||
"currentPasswordMustNotBeEmpty": "La contraseña actual no debe estar vacía.",
|
||||
"incorrectPassword": "Contraseña incorrecta.",
|
||||
"passwordsMustMatch": "Las contraseñas deben coincidir",
|
||||
"updatingUserProfile": "Actualizando perfil del usuario ...",
|
||||
"userProfileUpdated": "Perfil del usuario actualizado",
|
||||
"thereAreErrorsInTheFormPleaseFixThemAnd": "There are errors in the form. Please fix them and submit again.",
|
||||
"yourUpdatesCannotBeSaved": "Your updates cannot be saved.",
|
||||
"thePasswordIsIncorrectPossiblyJust": "The password is incorrect (possibly just changed in another window / session). Enter correct password and try again.",
|
||||
"theEmailIsAlreadyAssignedToAnotherUser": "The email is already assigned to another user. Enter another email and try again.",
|
||||
"account": "Account",
|
||||
"generalSettings": "General Settings",
|
||||
"fullName": "Full Name",
|
||||
"email": "Email",
|
||||
"thisAddressIsUsedForAccountRecoveryIn": "This address is used for account recovery in case you loose your password",
|
||||
"passwordChange": "Password Change",
|
||||
"youOnlyNeedToFillOutThisFormIfYouWantTo": "You only need to fill out this form if you want to change your current password",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"update": "Update",
|
||||
"accountManagementIsNotPossibleBecause": "Account management is not possible because Mailtrain is configured to use externally managed users.",
|
||||
"ifYouWantToChangeThePasswordUseThisLink": "If you want to change the password, use <1>this link</1>.",
|
||||
"usernameOrEmailMustNotBeEmpty": "Username or email must not be empty",
|
||||
"processing": "Processing ...",
|
||||
"ifTheUsernameEmailExistsInTheSystem": "If the username / email exists in the system, password reset link will be sent to the registered email.",
|
||||
"pleaseEnterYourUsernameEmailAndTryAgain": "Please enter your username / email and try again.",
|
||||
"passwordReset": "Password Reset",
|
||||
"pleaseProvideTheUsernameOrEmailAddress": "Please provide the username or email address that is registered with your Mailtrain account.",
|
||||
"weWillSendYouAnEmailThatWillAllowYouTo": "We will send you an email that will allow you to reset your password.",
|
||||
"usernameOrEmail": "Username or email",
|
||||
"sendEmail": "Send email",
|
||||
"userNameMustNotBeEmpty": "User name must not be empty",
|
||||
"passwordMustNotBeEmpty": "Password must not be empty",
|
||||
"verifyingCredentials": "Verifying credentials ...",
|
||||
"pleaseEnterYourCredentialsAndTryAgain": "Please enter your credentials and try again.",
|
||||
"invalidUsernameOrPassword": "Invalid username or password.",
|
||||
"forgotYourPassword?": "Olvidaste tu contraseña?",
|
||||
"signIn": "Iniciar sesión",
|
||||
"username": "Usuario",
|
||||
"password": "Contraseña",
|
||||
"rememberMe": "Recuérdame",
|
||||
"resettingPassword": "Resetting password ...",
|
||||
"passwordReset-1": "Password reset",
|
||||
"yourPasswordCannotBeReset": "Your password cannot be reset.",
|
||||
"thePasswordResetTokenHasExpired": "The password reset token has expired.",
|
||||
"clickHereToRequestANewPasswordResetLink": "Click here to request a new password reset link.",
|
||||
"validatingPasswordResetToken": "Validating password reset token ...",
|
||||
"thePasswordCannotBeReset": "The password cannot be reset",
|
||||
"setNewPasswordFor": "Set new password for",
|
||||
"resetPassword": "Reset password",
|
||||
"emailMustNotBeEmpty-1": "Email must not be empty",
|
||||
"theEmailIsAlreadyOnBlacklist": "The email is already on blacklist.",
|
||||
"saving": "Saving ...",
|
||||
"thereAreErrorsInTheFormPleaseFixThemAnd-1": "There are errors in the form. Please fix them and try again.",
|
||||
"removeFromBlacklist": "Remove from blacklist",
|
||||
"confirmRemovalFromBlacklist": "Confirm Removal From Blacklist",
|
||||
"areYouSureYouWantToRemoveEmailFromThe": "Are you sure you want to remove {{email}} from the blacklist?",
|
||||
"removingEmailFromTheBlacklist": "Removing {{email}} from the blacklist",
|
||||
"emailRemovedFromTheBlacklist": "{{email}} removed from the blacklist",
|
||||
"blacklist": "Blacklist",
|
||||
"addEmailToBlacklist-1": "Add Email to Blacklist",
|
||||
"addToBlacklist": "Add to Blacklist",
|
||||
"blacklistedEmails": "Blacklisted Emails",
|
||||
"createRegularCampaign": "Crear Campaña Regular",
|
||||
"sendSettings": "Configuración de envío",
|
||||
"workWithCampaignNamespace": "Trabajar con el espacio de nombres de la campaña",
|
||||
"createRssCampaign": "Create RSS Campaign",
|
||||
"createTriggeredCampaign": "Create Triggered Campaign",
|
||||
"editRegularCampaign": "Edit Regular Campaign",
|
||||
"editRssCampaign": "Edit RSS Campaign",
|
||||
"editTriggeredCampaign": "Edit Triggered Campaign",
|
||||
"template": "Template",
|
||||
"template_plural": "Templates",
|
||||
"customContentClonedFromTemplate": "Custom content cloned from template",
|
||||
"customContentClonedFromAnotherCampaign": "Custom content cloned from another campaign",
|
||||
"customContent": "Custom content",
|
||||
"url": "URL",
|
||||
"nameMustNotBeEmpty": "Name must not be empty",
|
||||
"sendConfigurationMustBeSelected": "Send configuration must be selected",
|
||||
"fromEmailMustNotBeEmpty": "\"From\" email must not be empty",
|
||||
"templateMustBeSelected": "Template must be selected",
|
||||
"campaignMustBeSelected": "Campaign must be selected",
|
||||
"typeMustBeSelected": "Type must be selected",
|
||||
"urlMustNotBeEmpty": "URL must not be empty",
|
||||
"rssFeedUrlMustBeGiven": "RSS feed URL must be given",
|
||||
"listMustBeSelected": "List must be selected",
|
||||
"segmentMustBeSelected": "Segment must be selected",
|
||||
"campaignSaved": "Campaign saved",
|
||||
"rssFeedUrl": "RSS Feed Url",
|
||||
"name": "Nombre",
|
||||
"id": "ID",
|
||||
"subscribers": "Suscriptores",
|
||||
"description": "Descripciçon",
|
||||
"namespace": "Espacio de nombres",
|
||||
"namespace_plural": "Espacios de nombres",
|
||||
"namespaceFiltering": "Filtrar espacio de nombres",
|
||||
"remove": "Eliminar",
|
||||
"insertNewEntryBeforeThisOne": "Insert new entry before this one",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"list": "List",
|
||||
"list_plural": "Lists",
|
||||
"segment": "Segment",
|
||||
"useAParticularSegment": "Use a particular segment",
|
||||
"lists": "Listas",
|
||||
"addList": "Add list",
|
||||
"type": "Type",
|
||||
"created": "Created",
|
||||
"override": "Override",
|
||||
"fromName": "\"From\" name",
|
||||
"fromEmailAddress": "\"From\" email address",
|
||||
"replytoEmailAddress": "\"Reply-to\" email address",
|
||||
"subjectLine": "\"Subject\" line",
|
||||
"loadingSendConfiguration": "Loading send configuration ...",
|
||||
"contentSource": "Content source",
|
||||
"selectingATemplateCreatesACampaign": "Selecting a template creates a campaign specific copy from it.",
|
||||
"campaign": "Campaña",
|
||||
"campaign_plural": "Campañas",
|
||||
"contentOfTheSelectedCampaignWillBeCopied": "Content of the selected campaign will be copied into this campaign.",
|
||||
"renderUrl": "Render URL",
|
||||
"ifAMessageIsSentThenThisUrlWillBePosTed": "If a message is sent then this URL will be POSTed to using Merge Tags as POST body. Use this if you want to generate the HTML message yourself.",
|
||||
"save": "Save",
|
||||
"saveAndEditContent": "Save and edit content",
|
||||
"saveCampaignAndGoToStatus": "Save campaign and go to status",
|
||||
"deletingCampaign": "Deleting campaign ...",
|
||||
"campaignDeleted": "Campaign deleted",
|
||||
"formCannotBeEditedBecauseTheCampaignIs": "Form cannot be edited because the campaign is currently being sent out. Wait till the sending is finished and refresh.",
|
||||
"thisIsTheCampaignIdDisplayedToThe": "This is the campaign ID displayed to the subscribers",
|
||||
"sendConfiguration": "Send configuration",
|
||||
"sendConfiguration_plural": "Send configurations",
|
||||
"customUnsubscribeUrl": "Custom unsubscribe URL",
|
||||
"disableOpenedTracking": "Disable opened tracking",
|
||||
"disableClickedTracking": "Disable clicked tracking",
|
||||
"delete": "Delete",
|
||||
"editCustomContent": "Edit Custom Content",
|
||||
"customTemplateEditor": "Custom template editor",
|
||||
"testSend": "Test send",
|
||||
"status": "Status",
|
||||
"sendingScheduled": "Sending scheduled",
|
||||
"sending": "Sending",
|
||||
"statistics": "Statistics",
|
||||
"edit": "Edit",
|
||||
"content": "Content",
|
||||
"files": "Files",
|
||||
"attachments": "Attachments",
|
||||
"triggers": "Triggers",
|
||||
"share": "Share",
|
||||
"createCampaign": "Create Campaign",
|
||||
"namespaceFilter": "Namespace filtering",
|
||||
"regular": "Regular",
|
||||
"rss": "RSS",
|
||||
"triggered": "Triggered",
|
||||
"campaigns": "Campañas",
|
||||
"campaignStatistics": "Campaign Statistics",
|
||||
"total": "Total",
|
||||
"delivered": "Delivered",
|
||||
"blacklisted": "Blacklisted",
|
||||
"bounced": "Bounced",
|
||||
"complaints": "Complaints",
|
||||
"unsubscribed": "Unsubscribed",
|
||||
"opened": "Opened",
|
||||
"clicked": "Clicked",
|
||||
"uniqueVisitors": "Unique visitors",
|
||||
"totalClicks": "Total clicks",
|
||||
"campaignLinks": "Campaign links",
|
||||
"subscriptionId": "Subscription ID",
|
||||
"listId": "List ID",
|
||||
"listNamespace": "List namespace",
|
||||
"opensCount": "Opens count",
|
||||
"countries": "Countries",
|
||||
"devices": "Devices",
|
||||
"desktop": "Desktop",
|
||||
"tv": "TV",
|
||||
"tablet": "Tablet",
|
||||
"phone": "Phone",
|
||||
"bot": "Bot",
|
||||
"car": "Car",
|
||||
"console": "Console",
|
||||
"distributionByDeviceType": "Distribution by device type",
|
||||
"loadingChart": "Loading chart",
|
||||
"deviceType": "Tipo de dispositivo",
|
||||
"count": "Cuenta",
|
||||
"unknown": "Desconocido",
|
||||
"distributionByCountry": "Distribucion por pais",
|
||||
"country": "País",
|
||||
"detailedStatistics": "Estadísticas detalladas",
|
||||
"listOfSubscribersThatOpenedTheCampaign": "List de suscriptores que han abierto la campaña",
|
||||
"subscriptionHasToBeSelectedToShowThe": "La suscripción debe ser seleccionada para mostrar la campaña a un usuario.",
|
||||
"previewCampaignAs": "Prevista de campaña como",
|
||||
"preview": "Prevista",
|
||||
"dateMustNotBeEmpty": "La fecha no debe estar vacía",
|
||||
"dateIsInvalid": "La fecha es inválida",
|
||||
"timeMustNotBeEmpty": "El tiempo no debe estar vacío",
|
||||
"timeIsInvalid": "El tiempo es inválido",
|
||||
"confirmReset": "Confirmar reinicio",
|
||||
"confirmLaunch": "Confirm launzamiento",
|
||||
"doYouWantToResetTheCampaign?All": "Quieres reiniciar la campaña? Todas las estadísticas se perderán.",
|
||||
"doYouWantToLaunchTheCampaign?All": "Quieres lanzar la campaña?",
|
||||
"no": "No",
|
||||
"yes": "Si",
|
||||
"subscribers-1": "subscribers",
|
||||
"sendStatus": "Send status",
|
||||
"campaignIsScheduledForDelivery": "Campaign is scheduled for delivery.",
|
||||
"campaignIsReadyToBeSentOut": "Campaign is ready to be sent out.",
|
||||
"sendLater": "Send later",
|
||||
"scheduleDeliveryAtAParticularDatetime": "Schedule delivery at a particular date/time",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"enter24hourTimeInFormatHhmmEg1348": "Enter 24-hour time in format HH:MM (e.g. 13:48)",
|
||||
"rescheduleSend": "Reschedule send",
|
||||
"scheduleSend": "Schedule send",
|
||||
"send": "Send",
|
||||
"viewStatistics": "View statistics",
|
||||
"campaignIsBeingSentOut": "Campaign is being sent out.",
|
||||
"stop": "Stop",
|
||||
"allMessagesSent!HitContinueIfYouYouWant": "All messages sent! Hit \"Continue\" if you you want to send this campaign to new subscribers.",
|
||||
"continue": "Continue",
|
||||
"reset": "Reset",
|
||||
"yourCampaignIsCurrentlyDisabledClick": "Your campaign is currently disabled. Click Enable button to start enable it.",
|
||||
"enable": "Enable",
|
||||
"yourCampaignIsEnabledAndSendingMessages": "Your campaign is enabled and sending messages.",
|
||||
"disable": "Disable",
|
||||
"campaignStatus": "Campaign Status",
|
||||
"targetListssegments": "Target lists/segments",
|
||||
"ifANewEntryIsFoundFromCampaignFeedANew": "If a new entry is found from campaign feed a new subcampaign is created of that entry and it will be listed here",
|
||||
"sendingTestEmail": "Sending test email",
|
||||
"subscriptionHasToBeSelected": "Subscription has to be selected.",
|
||||
"sendTestEmail": "Send Test Email",
|
||||
"cancel": "Cancel",
|
||||
"subscription": "Subscription",
|
||||
"idle": "Idle",
|
||||
"scheduled": "Scheduled",
|
||||
"paused": "Paused",
|
||||
"finished": "Finished",
|
||||
"inactive": "Inactive",
|
||||
"active": "Active",
|
||||
"campaignName": "Campaign \"{{name}}\"",
|
||||
"deliveredEmails": "Delivered Emails",
|
||||
"complained": "Complained",
|
||||
"subscribersThatComplained": "Subscribers that Complained",
|
||||
"emailsThatBounced": "Emails that Bounced",
|
||||
"subscribersThatUnsubscribed": "Subscribers that Unsubscribed",
|
||||
"clicks": "Clicks",
|
||||
"theseFilesArePubliclyAvailableViaHttpSo": "These files are publicly available via HTTP so that they can be linked to from the content of the campaign.",
|
||||
"theseFilesWillBeAttachedToTheCampaign": "These files will be attached to the campaign emails as proper attachments. This means they count towards to the eventual size of the email.",
|
||||
"triggerName": "Trigger \"{{name}}\"",
|
||||
"create": "Create",
|
||||
"valuesMustNotBeEmpty": "Values must not be empty",
|
||||
"valueMustBeANonnegativeNumber": "Value must be a non-negative number",
|
||||
"sourceCampaignMustNotBeEmpty": "Source campaign must not be empty",
|
||||
"triggerSaved": "Trigger saved",
|
||||
"deletingTrigger": "Deleting trigger ...",
|
||||
"triggerDeleted": "Trigger deleted",
|
||||
"editTrigger": "Edit Trigger",
|
||||
"createTrigger": "Create Trigger",
|
||||
"entity": "Entity",
|
||||
"selectTheTypeOfTheTriggerRule": "Select the type of the trigger rule.",
|
||||
"triggerFires": "Trigger fires",
|
||||
"event": "Event",
|
||||
"selectTheEventThatTriggersSendingThe": "Select the event that triggers sending the campaign.",
|
||||
"enabled": "Enabled",
|
||||
"daysAfter": "Days after",
|
||||
"latestOpen": "Latest open",
|
||||
"latestClick": "Latest click",
|
||||
"notOpened": "Not opened",
|
||||
"notClicked": "Not clicked",
|
||||
"close": "Close",
|
||||
"countFileAdded": "{{count}} file added",
|
||||
"countFileAdded_plural": "{{count}} files added",
|
||||
"countFileReplaced": "{{count}} file replaced",
|
||||
"countFileReplaced_plural": "{{count}} files replaced",
|
||||
"countFileIgnored": "{{count}} file ignored",
|
||||
"countFileIgnored_plural": "{{count}} files ignored",
|
||||
"countFileUploaded": "{{count}} file uploaded",
|
||||
"countFileUploaded_plural": "{{count}} files uploaded",
|
||||
"uploadingCountFile": "Uploading {{count}} file",
|
||||
"uploadingCountFile_plural": "Uploading {{count}} files",
|
||||
"fileUploadFailed": "File upload failed:",
|
||||
"noFilesToUpload": "No files to upload",
|
||||
"deletingFile": "Deleting file ...",
|
||||
"fileDeleted": "File deleted",
|
||||
"deleteFileFailed": "Delete file failed:",
|
||||
"size": "Size",
|
||||
"download": "Download",
|
||||
"confirmFileDeletion": "Confirm file deletion",
|
||||
"filesareYouSureToDeleteFile": "Are you sure you want to delete the file?",
|
||||
"dropCountFile": "Drop {{count}} file",
|
||||
"dropCountFile_plural": "Drop {{count}} files",
|
||||
"dropFilesHere": "Drop files here",
|
||||
"loading": "Loading ...",
|
||||
"openCalendar": "Open calendar",
|
||||
"select": "Select",
|
||||
"someoneElseHasIntroducedModificationIn": "Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.",
|
||||
"itSeemsThatSomeoneElseHasDeletedThe": "It seems that someone else has deleted the target namespace in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.",
|
||||
"itSeemsThatSomeoneElseHasDeletedThe-1": "It seems that someone else has deleted the entity in the meantime.",
|
||||
"customForms": "Custom forms",
|
||||
"report": "Report",
|
||||
"report_plural": "Reports",
|
||||
"reportTemplate": "Report template",
|
||||
"reportTemplate_plural": "Report templates",
|
||||
"mosaicoTemplate": "Mosaico template",
|
||||
"mosaicoTemplate_plural": "Mosaico templates",
|
||||
"cannoteDeleteNameDueToTheFollowing": "Cannote delete \"{{name}}\" due to the following dependencies:",
|
||||
"andMore": "... and more",
|
||||
"confirmDeletion": "Confirm Deletion",
|
||||
"areYouSureYouWantToDeleteName?": "Are you sure you want to delete \"{{name}}\"?",
|
||||
"namespacemustBeSelected": "Namespace must be selected",
|
||||
"mjml": "MJML",
|
||||
"html": "HTML",
|
||||
"countEntriesSelected": "{{ count }} entries selected.",
|
||||
"loading-1": "Loading ...",
|
||||
"customFormMustBeSelected": "Custom form must be selected",
|
||||
"listSaved": "List saved",
|
||||
"onestepIeNoEmailWithConfirmationLink": "One-step (i.e. no email with confirmation link)",
|
||||
"onestepWithUnsubscriptionFormIeNoEmail": "One-step with unsubscription form (i.e. no email with confirmation link)",
|
||||
"twostepIeAnEmailWithConfirmationLinkWill": "Two-step (i.e. an email with confirmation link will be sent)",
|
||||
"twostepWithUnsubscriptionFormIeAnEmail": "Two-step with unsubscription form (i.e. an email with confirmation link will be sent)",
|
||||
"manualIeUnsubscriptionHasToBePerformedBy": "Manual (i.e. unsubscription has to be performed by the list administrator)",
|
||||
"defaultMailtrainForms": "Default Mailtrain Forms",
|
||||
"customFormsSelectFormBelow": "Custom Forms (select form below)",
|
||||
"deletingList": "Deleting list ...",
|
||||
"listDeleted": "List deleted",
|
||||
"editList": "Edit List",
|
||||
"createList": "Create List",
|
||||
"thisIsTheListIdDisplayedToTheSubscribers": "This is the list ID displayed to the subscribers",
|
||||
"contactEmail": "Contact email",
|
||||
"contactEmailUsedInSubscriptionFormsAnd": "Contact email used in subscription forms and emails that are sent out. If not filled in, the admin email from the global settings will be used.",
|
||||
"homepage": "Homepage",
|
||||
"homepageUrlUsedInSubscriptionFormsAnd": "Homepage URL used in subscription forms and emails that are sent out. If not filled in, the default homepage from global settings will be used.",
|
||||
"recipientsNameTemplate": "Recipients name template",
|
||||
"specifyUsingMergeTagsOfThisListHowTo": "Specify using merge tags of this list how to construct full name of the recipient. This full name is used as \"To\" header when sending emails.",
|
||||
"sendConfigurationThatWillBeUsedFor": "Send configuration that will be used for sending out subscription-related emails.",
|
||||
"forms": "Forms",
|
||||
"webAndEmailFormsAndTemplatesUsedIn": "Web and email forms and templates used in subscription management process.",
|
||||
"allowPublicUsersToSubscribeThemselves": "Allow public users to subscribe themselves",
|
||||
"unsubscription": "Unsubscription",
|
||||
"selectHowAnUnsuscriptionRequestBy": "Select how an unsuscription request by subscriber is handled.",
|
||||
"unsubscribeHeader": "Unsubscribe header",
|
||||
"doNotSendListUnsubscribeHeaders": "Do not send List-Unsubscribe headers",
|
||||
"theCustomFormUsedForThisListYouCanCreate": "The custom form used for this list. You can create a form <1>here</1>.",
|
||||
"fields": "Fields",
|
||||
"segments": "Segments",
|
||||
"imports": "Imports",
|
||||
"customForms-1": "Custom Forms",
|
||||
"mergeTagIsInvalidMayMustBeUppercaseAnd": "Merge tag is invalid. May must be uppercase and contain only characters A-Z, 0-9, _. It must start with a letter.",
|
||||
"anotherFieldWithTheSameMergeTagExists": "Another field with the same merge tag exists. Please choose another merge tag.",
|
||||
"groupHasToBeSelected": "Group has to be selected",
|
||||
"defaultValueIsNotIntegerNumber": "Default value is not integer number",
|
||||
"defaultValueIsNotAProperlyFormattedDate": "Default value is not a properly formatted date",
|
||||
"defaultValueIsNotAProperlyFormatted": "Default value is not a properly formatted birthday date",
|
||||
"defaultValueIsNotOneOfTheAllowedOptions": "Default value is not one of the allowed options",
|
||||
"errrorOnLineLine": "Errror on line {{ line }}",
|
||||
"fieldSaved": "Field saved",
|
||||
"notVisible": "Not visible",
|
||||
"endOfList": "End of list",
|
||||
"fieldSettings": "Field settings",
|
||||
"defaultValue": "Default value",
|
||||
"defaultValueUsedWhenTheFieldIsEmpty": "Default value used when the field is empty.",
|
||||
"options": "Options",
|
||||
"dateFormat": "Date format",
|
||||
"mmddyyyy": "MM/DD/YYYY",
|
||||
"ddmmyyyy": "DD/MM/YYYY",
|
||||
"mmdd": "MM/DD",
|
||||
"ddmm": "DD/MM",
|
||||
"mergeTag": "Merge Tag",
|
||||
"group": "Group",
|
||||
"selectGroupToWhichTheOptionsShouldBelong": "Select group to which the options should belong.",
|
||||
"deletingField": "Deleting field ...",
|
||||
"fieldDeleted": "Field deleted",
|
||||
"editField": "Edit Field",
|
||||
"createField": "Create Field",
|
||||
"mergeTag-1": "Merge tag",
|
||||
"fieldOrder": "Field order",
|
||||
"listingsBefore": "Listings (before)",
|
||||
"selectTheFieldBeforeWhichThisFieldShould": "Select the field before which this field should appear in listings. To exclude the field from listings, select \"Not visible\".",
|
||||
"subscriptionFormBefore": "Subscription form (before)",
|
||||
"selectTheFieldBeforeWhichThisFieldShould-1": "Select the field before which this field should appear in new subscription form. To exclude the field from the new subscription form, select \"Not visible\".",
|
||||
"managementFormBefore": "Management form (before)",
|
||||
"selectTheFieldBeforeWhichThisFieldShould-2": "Select the field before which this field should appear in subscription management. To exclude the field from the subscription management form, select \"Not visible\".",
|
||||
"youCanControlTheAppearanceOfTheMergeTag": "You can control the appearance of the merge tag with this template. The template\n uses handlebars syntax and you can find all values from <1>{'{{values}}'}</1> array, for\n example <3>{'{{#each values}} {{this}} {{/each}}'}</3>. If template is not defined then\n multiple values are joined with commas.",
|
||||
"specifyTheOptionsToSelectFromInThe": "<0>Specify the options to select from in the following format:<1>key|label</1>. For example:</0>\n <2><0>au|Australia</0></2><3><0>at|Austria</0></3>",
|
||||
"defaultKeyEgAuUsedWhenTheFieldIsEmpty": "Default key (e.g. <1>au</1> used when the field is empty.')",
|
||||
"youCanControlTheAppearanceOfTheMergeTag-1": "You can control the appearance of the merge tag with this template. The template\n uses handlebars syntax and you can find all values from <1>{'{{values}}'}</1> array.\n Each entry in the array is an object with attributes <3>key</3> and <5>label</5>.\n For example <7>{'{{#each values}} {{this.value}} {{/each}}'}</7>. If template is not defined then\n multiple values are joined with commas.",
|
||||
"youCanUseThisTemplateToRenderJsonValues": "You can use this template to render JSON values (if the JSON is an array then the array is\n exposed as <1>values</1>, otherwise you can access the JSON keys directly).",
|
||||
"text": "Text",
|
||||
"website": "Website",
|
||||
"multilineText": "Multi-line text",
|
||||
"gpgPublicKey": "GPG Public Key",
|
||||
"number": "Number",
|
||||
"checkboxesFromOptionFields": "Checkboxes (from option fields)",
|
||||
"radioButtonsFromOptionFields": "Radio Buttons (from option fields)",
|
||||
"dropDownFromOptionFields": "Drop Down (from option fields)",
|
||||
"radioButtonsEnumerated": "Radio Buttons (enumerated)",
|
||||
"dropDownEnumerated": "Drop Down (enumerated)",
|
||||
"birthday": "Birthday",
|
||||
"jsonValueForCustomRendering": "JSON value for custom rendering",
|
||||
"option": "Option",
|
||||
"thePlaintextVersionForThisEmail": "The plaintext version for this email",
|
||||
"layout": "Layout",
|
||||
"formInputStyle": "Form Input Style",
|
||||
"thisCssStylesheetDefinesTheAppearanceOf": "This CSS stylesheet defines the appearance of form input elements and alerts",
|
||||
"webSubscribe": "Web - Subscribe",
|
||||
"webConfirmSubscriptionNotice": "Web - Confirm Subscription Notice",
|
||||
"mailConfirmSubscriptionMjml": "Mail - Confirm Subscription (MJML)",
|
||||
"mailConfirmSubscriptionText": "Mail - Confirm Subscription (Text)",
|
||||
"mailAlreadySubscribedMjml": "Mail - Already Subscribed (MJML)",
|
||||
"mailAlreadySubscribedText": "Mail - Already Subscribed (Text)",
|
||||
"webSubscribedNotice": "Web - Subscribed Notice",
|
||||
"mailSubscriptionConfirmedMjml": "Mail - Subscription Confirmed (MJML)",
|
||||
"mailSubscriptionConfirmedText": "Mail - Subscription Confirmed (Text)",
|
||||
"webManagePreferences": "Web - Manage Preferences",
|
||||
"webManageAddress": "Web - Manage Address",
|
||||
"mailConfirmAddressChangeMjml": "Mail - Confirm Address Change (MJML)",
|
||||
"mailConfirmAddressChangeText": "Mail - Confirm Address Change (Text)",
|
||||
"webUpdatedNotice": "Web - Updated Notice",
|
||||
"webUnsubscribe": "Web - Unsubscribe",
|
||||
"webConfirmUnsubscriptionNotice": "Web - Confirm Unsubscription Notice",
|
||||
"mailConfirmUnsubscriptionMjml": "Mail - Confirm Unsubscription (MJML)",
|
||||
"mailConfirmUnsubscriptionText": "Mail - Confirm Unsubscription (Text)",
|
||||
"webUnsubscribedNotice": "Web - Unsubscribed Notice",
|
||||
"mailUnsubscriptionConfirmedMjml": "Mail - Unsubscription Confirmed (MJML)",
|
||||
"mailUnsubscriptionConfirmedText": "Mail - Unsubscription Confirmed (Text)",
|
||||
"webManualUnsubscribeNotice": "Web - Manual Unsubscribe Notice",
|
||||
"privacyPolicy": "Privacy policy",
|
||||
"general": "General",
|
||||
"subscribe": "Subscribe",
|
||||
"manage": "Manage",
|
||||
"unsubscribe": "Unsubscribe",
|
||||
"dataProtection": "Data protection",
|
||||
"listOfErrorsInTemplates": "List of errors in templates",
|
||||
"formsSaved": "Forms saved",
|
||||
"deletingForm": "Deleting form ...",
|
||||
"formDeleted": "Form deleted",
|
||||
"editCustomForms": "Edit Custom Forms",
|
||||
"createCustomForms": "Create Custom Forms",
|
||||
"formsPreview": "Forms Preview",
|
||||
"listToPreviewOn": "List To Preview On",
|
||||
"selectListWhoseFieldsWillBeUsedToPreview": "Select list whose fields will be used to preview the forms.",
|
||||
"noteTheseLinksAreSolelyForAQuickPreview": "Note: These links are solely for a quick preview. To get the address of the subscription form, go to the list's subscribers and click on \"Subscription Form\".",
|
||||
"formPreview": "Form preview:",
|
||||
"templates": "Templates",
|
||||
"customFormsUseMjmlForFormattingSeeThe": "Custom forms use MJML for formatting. See the MJML documentation <1>here</1>",
|
||||
"createCustomForm": "Create Custom Form",
|
||||
"fileMustBeSelected": "File must be selected",
|
||||
"csvDelimiterMustNotBeEmpty": "CSV delimiter must not be empty",
|
||||
"emailMappingHasToBeProvided": "Email mapping has to be provided",
|
||||
"importSaved": "Import saved",
|
||||
"file": "File",
|
||||
"delimiter": "Delimiter",
|
||||
"preparationInProgressPleaseWaitTillItIs": "Preparation in progress. Please wait till it is done or visit this page later.",
|
||||
"––Select ––": "–– Select ––",
|
||||
"eg": "e.g.:",
|
||||
"checkImportedEmails": "Check imported emails",
|
||||
"mapping": "Mapping",
|
||||
"saveAndEditSettings": "Save and edit settings",
|
||||
"saveAndRun": "Save and Run",
|
||||
"deletingImport": "Deleting import ...",
|
||||
"importDeleted": "Import deleted",
|
||||
"editImport": "Edit Import",
|
||||
"createImport": "Create Import",
|
||||
"source": "Source",
|
||||
"lastRun": "Last run",
|
||||
"detailedStatus": "Detailed status",
|
||||
"row": "Row",
|
||||
"reason": "Reason",
|
||||
"importRunStatus": "Import Run Status",
|
||||
"importName": "Import name",
|
||||
"importSource": "Import source",
|
||||
"runStarted": "Run started",
|
||||
"runFinished": "Run finished",
|
||||
"runStatus": "Run status",
|
||||
"processedEntries": "Processed entries",
|
||||
"newEntries": "New entries",
|
||||
"failedEntries": "Failed entries",
|
||||
"error": "Error",
|
||||
"failedRows": "Failed Rows",
|
||||
"started": "Started",
|
||||
"processed": "Processed",
|
||||
"new": "New",
|
||||
"failed": "Failed",
|
||||
"importStatus": "Import Status",
|
||||
"actions": "Actions",
|
||||
"start": "Start",
|
||||
"importRuns": "Import Runs",
|
||||
"csvFile": "CSV file",
|
||||
"preparing": "Preparing",
|
||||
"stopping": "Stopping",
|
||||
"ready": "Ready",
|
||||
"preparationFailed": "Preparation failed",
|
||||
"running": "Running",
|
||||
"starting": "Starting",
|
||||
"basicImportOfSubscribers": "Basic import of subscribers",
|
||||
"unsubscribeEmails": "Unsubscribe emails",
|
||||
"listName": "List \"{{name}}\"",
|
||||
"fieldName-1": "Field \"{{name}}\"",
|
||||
"segmentName": "Segment \"{{name}}\"",
|
||||
"importName-1": "Import \"{{name}}\"",
|
||||
"run": "Run",
|
||||
"customFormsName": "Custom Forms \"{{name}}\"",
|
||||
"newRule": "New rule",
|
||||
"segmentSaved": "Segment saved",
|
||||
"deletingSegment": "Deleting segment ...",
|
||||
"segmentDeleted": "Segment deleted",
|
||||
"editSegment": "Edit Segment",
|
||||
"createSegment": "Create Segment",
|
||||
"saveAndStay": "Save and Stay",
|
||||
"saveAndLeave": "Save and Leave",
|
||||
"segmentOptions": "Segment Options",
|
||||
"toplevelMatchType": "Toplevel match type",
|
||||
"addCompositeRule": "Add Composite Rule",
|
||||
"addRule": "Add Rule",
|
||||
"rules": "Rules",
|
||||
"fieldMustBeSelected": "Field must be selected",
|
||||
"field": "Field",
|
||||
"select-1": "-- Select --",
|
||||
"ruleOptions": "Rule Options",
|
||||
"ok": "OK",
|
||||
"allRulesMustMatch": "All rules must match",
|
||||
"atLeastOneRuleMustMatch": "At least one rule must match",
|
||||
"noRuleMayMatch": "No rule may match",
|
||||
"equalTo": "Equal to",
|
||||
"valueInColumnColNameIsEqualToValue": "Value in column \"{{colName}}\" is equal to \"{{value}}\"",
|
||||
"matchWithSqlLike": "Match (with SQL LIKE)",
|
||||
"valueInColumnColNameMatchesWithSqlLike": "Value in column \"{{colName}}\" matches (with SQL LIKE) \"{{value}}\"",
|
||||
"matchWithRegularExpressions": "Match (with regular expressions)",
|
||||
"valueInColumnColNameMatchesWithRegular": "Value in column \"{{colName}}\" matches (with regular expressions) \"{{value}}\"",
|
||||
"alphabeticallyBefore": "Alphabetically before",
|
||||
"valueInColumnColNameIsAlphabetically": "Value in column \"{{colName}}\" is alphabetically before \"{{value}}\"",
|
||||
"alphabeticallyBeforeOrEqualTo": "Alphabetically before or equal to",
|
||||
"valueInColumnColNameIsAlphabetically-1": "Value in column \"{{colName}}\" is alphabetically before or equal to \"{{value}}\"",
|
||||
"alphabeticallyAfter": "Alphabetically after",
|
||||
"valueInColumnColNameIsAlphabetically-2": "Value in column \"{{colName}}\" is alphabetically after \"{{value}}\"",
|
||||
"alphabeticallyAfterOrEqualTo": "Alphabetically after or equal to",
|
||||
"valueInColumnColNameIsAlphabetically-3": "Value in column \"{{colName}}\" is alphabetically after or equal to \"{{value}}\"",
|
||||
"valueInColumnColNameIsEqualToValue-1": "Value in column \"{{colName}}\" is equal to {{value}}",
|
||||
"lessThan": "Less than",
|
||||
"valueInColumnColNameIsLessThanValue": "Value in column \"{{colName}}\" is less than {{value}}",
|
||||
"lessThanOrEqualTo": "Less than or equal to",
|
||||
"valueInColumnColNameIsLessThanOrEqualTo": "Value in column \"{{colName}}\" is less than or equal to {{value}}",
|
||||
"greaterThan": "Greater than",
|
||||
"valueInColumnColNameIsGreaterThanValue": "Value in column \"{{colName}}\" is greater than {{value}}",
|
||||
"greaterThanOrEqualTo": "Greater than or equal to",
|
||||
"valueInColumnColNameIsGreaterThanOrEqual": "Value in column \"{{colName}}\" is greater than or equal to {{value}}",
|
||||
"on": "On",
|
||||
"dateInColumnColNameIsValue": "Date in column \"{{colName}}\" is {{value}}",
|
||||
"before": "Before",
|
||||
"dateInColumnColNameIsBeforeValue": "Date in column \"{{colName}}\" is before {{value}}",
|
||||
"beforeOrOn": "Before or on",
|
||||
"dateInColumnColNameIsBeforeOrOnValue": "Date in column \"{{colName}}\" is before or on {{value}}",
|
||||
"after": "After",
|
||||
"dateInColumnColNameIsAfterValue": "Date in column \"{{colName}}\" is after {{value}}",
|
||||
"afterOrOn": "After or on",
|
||||
"dateInColumnColNameIsAfterOrOnValue": "Date in column \"{{colName}}\" is after or on {{value}}",
|
||||
"onXthDayBeforeafterCurrentDate": "On x-th day before/after current date",
|
||||
"dateInColumnColNameIsTheCurrentDate": "Date in column \"{{colName}}\" is the current date",
|
||||
"dateInColumnColNameIsTheValuethDayAfter": "Date in column \"{{colName}}\" is the {{value}}-th day after the current date",
|
||||
"dateInColumnColNameIsTheValuethDayBefore": "Date in column \"{{colName}}\" is the {{value}}-th day before the current date",
|
||||
"beforeXthDayBeforeafterCurrentDate": "Before x-th day before/after current date",
|
||||
"dateInColumnColNameIsBeforeTheCurrent": "Date in column \"{{colName}}\" is before the current date",
|
||||
"dateInColumnColNameIsBeforeTheValuethDay": "Date in column \"{{colName}}\" is before the {{value}}-th day after the current date",
|
||||
"dateInColumnColNameIsBeforeTheValuethDay-1": "Date in column \"{{colName}}\" is before the {{value}}-th day before the current date",
|
||||
"beforeOrOnXthDayBeforeafterCurrentDate": "Before or on x-th day before/after current date",
|
||||
"dateInColumnColNameIsBeforeOrOnThe": "Date in column \"{{colName}}\" is before or on the current date",
|
||||
"dateInColumnColNameIsBeforeOrOnThe-1": "Date in column \"{{colName}}\" is before or on the {{value}}-th day after the current date",
|
||||
"dateInColumnColNameIsBeforeOrOnThe-2": "Date in column \"{{colName}}\" is before or on the {{value}}-th day before the current date",
|
||||
"afterXthDayBeforeafterCurrentDate": "After x-th day before/after current date",
|
||||
"dateInColumnColNameIsAfterTheCurrentDate": "Date in column \"{{colName}}\" is after the current date",
|
||||
"dateInColumnColNameIsAfterTheValuethDay": "Date in column \"{{colName}}\" is after the {{value}}-th day after the current date",
|
||||
"dateInColumnColNameIsAfterTheValuethDay-1": "Date in column \"{{colName}}\" is after the {{value}}-th day before the current date",
|
||||
"afterOrOnXthDayBeforeafterCurrentDate": "After or on x-th day before/after current date",
|
||||
"dateInColumnColNameIsAfterOrOnTheCurrent": "Date in column \"{{colName}}\" is after or on the current date",
|
||||
"dateInColumnColNameIsAfterOrOnTheValueth": "Date in column \"{{colName}}\" is after or on the {{value}}-th day after the current date",
|
||||
"dateInColumnColNameIsAfterOrOnTheValueth-1": "Date in column \"{{colName}}\" is after or on the {{value}}-th day before the current date",
|
||||
"isSelected": "Is selected",
|
||||
"valueInColumnColNameIsSelected": "Value in column \"{{colName}}\" is selected",
|
||||
"isNotSelected": "Is not selected",
|
||||
"valueInColumnColNameIsNotSelected": "Value in column \"{{colName}}\" is not selected",
|
||||
"keyEqualTo": "Key equal to",
|
||||
"theSelectedKeyInColumnColNameIsEqualTo": "The selected key in column \"{{colName}}\" is equal to \"{{value}}\"",
|
||||
"keyMatchWithSqlLike": "Key match (with SQL LIKE)",
|
||||
"theSelectedKeyInColumnColNameMatchesWith": "The selected key in column \"{{colName}}\" matches (with SQL LIKE) \"{{value}}\"",
|
||||
"keyMatchWithRegularExpressions": "Key match (with regular expressions)",
|
||||
"theSelectedKeyInColumnColNameMatchesWith-1": "The selected key in column \"{{colName}}\" matches (with regular expressions) \"{{value}}\"",
|
||||
"keyAlphabeticallyBefore": "Key alphabetically before",
|
||||
"theSelectedKeyInColumnColNameIs": "The selected key in column \"{{colName}}\" is alphabetically before \"{{value}}\"",
|
||||
"keyAlphabeticallyBeforeOrEqualTo": "Key alphabetically before or equal to",
|
||||
"theSelectedKeyInColumnColNameIs-1": "The selected key in column \"{{colName}}\" is alphabetically before or equal to \"{{value}}\"",
|
||||
"keyAlphabeticallyAfter": "Key alphabetically after",
|
||||
"theSelectedKeyInColumnColNameIs-2": "The selected key in column \"{{colName}}\" is alphabetically after \"{{value}}\"",
|
||||
"keyAlphabeticallyAfterOrEqualTo": "Key alphabetically after or equal to",
|
||||
"theSelectedKeyInColumnColNameIs-3": "The selected key in column \"{{colName}}\" is alphabetically after or equal to \"{{value}}\"",
|
||||
"value": "Value",
|
||||
"valueMustNotBeEmpty": "Value must not be empty",
|
||||
"valueMustBeANumber": "Value must be a number",
|
||||
"numberOfDays": "Number of days",
|
||||
"beforeAfter": "Before/After",
|
||||
"beforeCurrentDate": "Before current date",
|
||||
"afterCurrentDate": "After current date",
|
||||
"numberOfDaysMustNotBeEmpty": "Number of days must not be empty",
|
||||
"numberOfDaysMustBeANumber": "Number of days must be a number",
|
||||
"emailAddress-1": "Email address",
|
||||
"signupCountry": "Signup country",
|
||||
"signUpDate": "Sign up date",
|
||||
"anotherSubscriptionWithTheSameEmail": "Another subscription with the same email already exists.",
|
||||
"susbscriptionSaved": "Susbscription saved",
|
||||
"itSeemsThatAnotherSubscriptionWithThe": "It seems that another subscription with the same email has been created in the meantime. Refresh your page to start anew. Please note that your changes will be lost.",
|
||||
"notSelected": "Not selected",
|
||||
"areYouSureYouWantToDeleteSubscriptionFor": "Are you sure you want to delete subscription for \"{{email}}\"?",
|
||||
"deletingSubscription": "Deleting subscription ...",
|
||||
"subscriptionDeleted": "Subscription deleted",
|
||||
"editSubscription": "Edit Subscription",
|
||||
"createSubscription": "Create Subscription",
|
||||
"timezone": "Timezone",
|
||||
"subscriptionStatus": "Subscription status",
|
||||
"testUser?": "Test user?",
|
||||
"ifCheckedThenThisSubscriptionCanBeUsed": "If checked then this subscription can be used for previewing campaign messages",
|
||||
"erased": "[ERASED]",
|
||||
"confirmUnsubscription": "Confirm Unsubscription",
|
||||
"areYouSureYouWantToUnsubscribeEmail?": "Are you sure you want to unsubscribe {{email}}?",
|
||||
"unsubscribingEmail": "Unsubscribing {{email}}",
|
||||
"emailUnsubscribed": "{{email}} unsubscribed",
|
||||
"confirmEmailBlacklisting": "Confirm Email Blacklisting",
|
||||
"areYouSureYouWantToBlacklistEmail?": "Are you sure you want to blacklist {{email}}?",
|
||||
"blacklistingEmail": "Blacklisting {{email}}",
|
||||
"emailBlacklisted": "{{email}} blacklisted",
|
||||
"allSubscriptions": "All subscriptions",
|
||||
"subscriptionForm": "Subscription Form",
|
||||
"exportAsCsv": "Export as CSV",
|
||||
"addSubscriber": "Add Subscriber",
|
||||
"subscribed": "Subscribed",
|
||||
"unubscribed": "Unubscribed",
|
||||
"parentNamespaceMustBeSelected": "Parent Namespace must be selected",
|
||||
"namespaceSaved": "Namespace saved",
|
||||
"thereHasBeenALoopDetectedInTheAssignment": "There has been a loop detected in the assignment of the parent namespace. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew. Please note that your changes will be lost.",
|
||||
"itSeemsThatTheParentNamespaceHasBeen": "It seems that the parent namespace has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.",
|
||||
"deletingNamespace": "Deleting namespace ...",
|
||||
"namespaceDeleted": "Namespace deleted",
|
||||
"editNamespace": "Edit Namespace",
|
||||
"createNamespace": "Create Namespace",
|
||||
"parentNamespace": "Parent Namespace",
|
||||
"namespaces": "Namespaces",
|
||||
"namespaceName": "Namespace \"{{name}}\"",
|
||||
"reportTemplateMustBeSelected": "Report template must be selected",
|
||||
"exactlyOneItemHasToBeSelected": "Exactly one item has to be selected",
|
||||
"atLeastCountItemsHaveToBeSelected": "At least {{ count }} item(s) have to be selected",
|
||||
"atMostCountItemsCanToBeSelected": "At most {{ count }} item(s) can to be selected",
|
||||
"reportParametersAreNotSelectedWaitFor": "Report parameters are not selected. Wait for them to get displayed and then fill them in.",
|
||||
"reportSaved": "Report saved",
|
||||
"unknownFieldTypeType": "Unknown field type \"{{type}}\"",
|
||||
"deletingReport": "Deleting report ...",
|
||||
"reportDeleted": "Report deleted",
|
||||
"editReport": "Edit Report",
|
||||
"createReport": "Create Report",
|
||||
"reportTemplate-1": "Report Template",
|
||||
"reportParameters": "Report parameters",
|
||||
"loadingReportTemplate": "Loading report template...",
|
||||
"processing-1": "Processing",
|
||||
"view": "View",
|
||||
"refreshReport": "Refresh report",
|
||||
"reportGenerationFailed": "Report generation failed",
|
||||
"regenerateReport": "Regenerate report",
|
||||
"viewConsoleOutput": "View console output",
|
||||
"reportTemplates": "Report Templates",
|
||||
"reports": "Reports",
|
||||
"reportName": "Report {{name}}",
|
||||
"loadingReport": "Loading report ...",
|
||||
"outputForReportName": "Output for report {{name}}",
|
||||
"loadingReportOutput": "Loading report output ...",
|
||||
"reportIsBeingGenerated": "Report is being generated",
|
||||
"reportNotGenerated": "Report not generated",
|
||||
"refresh": "Refresh",
|
||||
"reportName-1": "Report \"{{name}}\"",
|
||||
"output": "Output",
|
||||
"templateName": "Template \"{{name}}\"",
|
||||
"mimeTypeMustBeSelected": "MIME Type must be selected",
|
||||
"syntaxErrorInTheUserFieldsSpecification": "Syntax error in the user fields specification",
|
||||
"reportTemplateSaved": "Report template saved",
|
||||
"deletingReportTemplate": "Deleting report template ...",
|
||||
"reportTemplateDeleted": "Report template deleted",
|
||||
"editReportTemplate": "Edit Report Template",
|
||||
"createReportTemplate": "Create Report Template",
|
||||
"csv": "CSV",
|
||||
"userSelectableFields": "User selectable fields",
|
||||
"jsonSpecificationOfUserSelectableFields": "JSON specification of user selectable fields.",
|
||||
"dataProcessingCode": "Data processing code",
|
||||
"renderingTemplate": "Rendering template",
|
||||
"writeTheBodyOfTheJavaScriptFunctionWith": "Write the body of the JavaScript function with signature <1>function(inputs, callback)</1> that returns an object to be rendered by the Handlebars template below.",
|
||||
"useHtmlWithHandlebarsSyntaxSee": "Use HTML with Handlebars syntax. See documentation <1>here</1>.",
|
||||
"blank": "Blank",
|
||||
"openCounts": "Open counts",
|
||||
"openCountsAsCsv": "Open counts as CSV",
|
||||
"aggregatedOpenCounts": "Aggregated open counts",
|
||||
"current": "(current)",
|
||||
"toggleNavigation": "Toggle navigation",
|
||||
"administration": "Administracion",
|
||||
"users": "Users",
|
||||
"globalSettings": "Global Settings",
|
||||
"sendConfigurations": "Send configurations",
|
||||
"logOut": "Log out",
|
||||
"home": "Home",
|
||||
"sourceOnGitHub": "Source on GitHub",
|
||||
"mailerTypeMustBeSelected": "Mailer type must be selected",
|
||||
"verpHostnameMustNotBeEmpty": "VERP hostname must not be empty",
|
||||
"sendConfigurationSaved": "Send configuration saved",
|
||||
"deletingSendConfiguration": "Deleting send configuration ...",
|
||||
"sendConfigurationDeleted": "Send configuration deleted",
|
||||
"editSendConfiguration": "Edit Send Configuration",
|
||||
"createSendConfiguration": "Create Send Configuration",
|
||||
"emailHeader": "Email Header",
|
||||
"defaultFromEmail": "Default \"from\" email",
|
||||
"overridable": "Overridable",
|
||||
"defaultFromName": "Default \"from\" name",
|
||||
"defaultReplytoEmail": "Default \"reply-to\" email",
|
||||
"subject": "Subject",
|
||||
"xMailer": "X-Mailer",
|
||||
"verpBounceHandling": "VERP Bounce Handling",
|
||||
"verpStatus": "VERP status",
|
||||
"serverHostname": "Server hostname",
|
||||
"theVerpServerHostnameEgBouncesexamplecom": "The VERP server hostname, eg. bounces.example.com",
|
||||
"verpBounceHandlingServerHostnameThis": "VERP bounce handling server hostname. This hostname is used in the SMTP envelope FROM address and the MX DNS records should point to this server",
|
||||
"disableSenderHeader": "Disable sender header",
|
||||
"withDmarcTheReturnPathAndFromAddressMust": "With DMARC, the Return-Path and From address must match the same domain. By default we get around this by using the VERP address in the Sender header, with the side effect that some email clients diplay an ugly on behalf of message. You can safely disable this Sender header if you're not using DMARC or your VERP hostname is in the same domain as the From address.",
|
||||
"mailtrainIsAbleToUseVerpBasedRoutingTo": "<0>Mailtrain is able to use VERP based routing to detect bounces. In this case the message is sent to the recipient using a custom VERP address as the return path of the message. If the message is not accepted a bounce email is sent to this special VERP address and thus a bounce is detected.</0>",
|
||||
"toGetVerpWorkingYouNeedToSetUpADnsMx": "<0>To get VERP working you need to set up a DNS MX record that points to your Mailtrain hostname. You must also ensure that Mailtrain VERP interface is available from port 25 of your server (port 25 usually requires root user privileges). This way if anyone tries to send email to someuser@verp-hostname then the email should end up to this server.</0>",
|
||||
"verpUsuallyOnlyWorksIfYouAreUsingYourOwn": "<0>VERP usually only works if you are using your own SMTP server. Regular relay services (SES, SparkPost, Gmail etc.) tend to remove the VERP address from the message.</0>",
|
||||
"verpBounceHandlingServerIsNotEnabled": "<0>VERP bounce handling server is not enabled. Modify your server configuration file and restart server to enable it.</0>",
|
||||
"sendConfigurations-1": "Send Configurations",
|
||||
"labelMustNotBeEmpty": "{{label}} must not be empty",
|
||||
"labelMustBeANumber": "{{label}} must be a number",
|
||||
"genericSmtp": "Generic SMTP",
|
||||
"zoneMta": "Zone MTA",
|
||||
"amazonSes": "Amazon SES",
|
||||
"doNotUseEncryption": "Do not use encryption",
|
||||
"useTls –UsuallySelectedForPort465": "Use TLS – usually selected for port 465",
|
||||
"useStarttls –UsuallySelectedForPort587": "Use STARTTLS – usually selected for port 587 and 25",
|
||||
"useast1": "US-EAST-1",
|
||||
"uswest2": "US-WEST-2",
|
||||
"euwest1": "EU-WEST-1",
|
||||
"builtinZoneMta": "Built-in ZoneMTA",
|
||||
"dynamicConfigurationOfDkimKeysViaZoneMt": "Dynamic configuration of DKIM keys via ZoneMTA's Mailtrain plugin",
|
||||
"dynamicConfigurationOfDkimKeysViaZoneMt-1": "Dynamic configuration of DKIM keys via ZoneMTA's HTTP config plugin",
|
||||
"noDynamicConfigurationOfDkimKeys": "No dynamic configuration of DKIM keys",
|
||||
"mailerSettings": "Mailer Settings",
|
||||
"mailerType": "Mailer type",
|
||||
"hostname": "Hostname",
|
||||
"hostnameEgSmtpexamplecom": "Hostname, eg. smtp.example.com",
|
||||
"port": "Port",
|
||||
"portEg465AutodetectedIfLeftBlank": "Port, eg. 465. Autodetected if left blank",
|
||||
"encryption": "Encryption",
|
||||
"enableSmtpAuthentication": "Enable SMTP authentication",
|
||||
"usernameEgMyaccount@examplecom": "Username, eg. myaccount@example.com",
|
||||
"advancedMailerSettings": "Advanced Mailer Settings",
|
||||
"logSmtpTransactions": "Log SMTP transactions",
|
||||
"allowSelfsignedCertificates": "Allow self-signed certificates",
|
||||
"maxConnections": "Max connections",
|
||||
"theCountOfMaxConnectionsEg10": "The count of max connections, eg. 10",
|
||||
"theCountOfMaximumSimultaneousConnections": "The count of maximum simultaneous connections to make against the SMTP server (defaults to 5). This limit is per sending process.",
|
||||
"maxMessages": "Max messages",
|
||||
"theCountOfMaxMessagesEg100": "The count of max messages, eg. 100",
|
||||
"theNumberOfMessagesToSendThroughASingle": "The number of messages to send through a single connection before the connection is closed and reopened (defaults to 100)",
|
||||
"throttling": "Throttling",
|
||||
"messagesPerHourEg1000": "Messages per hour eg. 1000",
|
||||
"maximumNumberOfMessagesToSendInAnHour": "Maximum number of messages to send in an hour. Leave empty or zero for no throttling. If your provider uses a different speed limit (messages/minute or messages/second) then convert this limit into messages/hour (1m/s => 3600m/h). This limit is per sending process.",
|
||||
"dynamicConfiguration": "Dynamic configuration",
|
||||
"dkimSigning": "DKIM Signing",
|
||||
"zoneMtaDkimApiKey": "ZoneMTA DKIM API key",
|
||||
"secretValueKnownToZoneMtaForRequesting": "Secret value known to ZoneMTA for requesting DKIM key information. If this value was generated by the Mailtrain installation script then you can keep it as it is.",
|
||||
"dkimDomain": "DKIM domain",
|
||||
"leaveBlankToUseTheSenderEmailAddress": "Leave blank to use the sender email address domain.",
|
||||
"dkimKeySelector": "DKIM key selector",
|
||||
"signingIsDisabledWithoutAValidSelector": "Signing is disabled without a valid selector value.",
|
||||
"dkimPrivateKey": "DKIM private key",
|
||||
"beginsWithBeginRsaPrivateKey": "Begins with \"-----BEGIN RSA PRIVATE KEY-----\"",
|
||||
"signingIsDisabledWithoutAValidPrivateKey": "Signing is disabled without a valid private key.",
|
||||
"accessKey": "Access key",
|
||||
"awsAccessKeyId": "AWS access key ID",
|
||||
"awsSecretAccessKey": "AWS secret access key",
|
||||
"region": "Region",
|
||||
"ifYouAreUsingZoneMtaThenMailtrainCan": "<0>If you are using ZoneMTA then Mailtrain can provide a DKIM key for signing all outgoing messages. Other services usually provide their own means to DKIM sign your messages.</0>",
|
||||
"doNotUseSensitiveKeysHereThePrivateKeyIs": "<0>Do not use sensitive keys here. The private key is not encrypted in the database.</0>",
|
||||
"globalSettingsSaved": "Global settings saved",
|
||||
"adminEmail": "Admin email",
|
||||
"thisEmailIsUsedAsTheMainContactAndAsA": "This email is used as the main contact and as a default email address if no email address is specified in list settings.",
|
||||
"defaultHomepageUrl": "Default homepage URL",
|
||||
"thisUrlWillBeUsedInListSubscriptionForms": "This URL will be used in list subscription forms if no homepage is specified in list settings.",
|
||||
"trackingId": "Tracking ID",
|
||||
"uaxxxxxxx": "UA-XXXXX-XX",
|
||||
"enterGoogleAnalyticsTrackingCode": "Enter Google Analytics tracking code",
|
||||
"googleMapsApiKey": "Google Maps API Key",
|
||||
"xxxxxx": "XXXXXX",
|
||||
"theMapOverviewInCampaignStatistics": "The map overview in campaign statistics requires a Google Maps API key. Please enter it here. If no key is given, Google may throttle map requests, which will result in occassional unavailability of the map in the campaign statistics.",
|
||||
"frontpageShoutOut": "Frontpage shout out",
|
||||
"htmlCodeShownInTheFrontPageHeaderSection": "HTML code shown in the front page header section",
|
||||
"gpgSigning": "GPG Signing",
|
||||
"privateKeyPassphrase": "Private key passphrase",
|
||||
"passphraseForTheKeyIfSet": "Passphrase for the key if set",
|
||||
"onlyFillThisIfYourPrivateKeyIsEncrypted": "Only fill this if your private key is encrypted with a passphrase",
|
||||
"gpgPrivateKey": "GPG private key",
|
||||
"beginsWithBeginPgpPrivateKeyBlock": "Begins with '-----BEGIN PGP PRIVATE KEY BLOCK-----'",
|
||||
"thisValueIsOptionalIfYouDoNotProvideA": "This value is optional. If you do not provide a private key GPG encrypted messages are sent without signing.",
|
||||
"onlyMessagesThatAreEncryptedCanBeSigned": "<0>Only messages that are encrypted can be signed. Subsribers who have not set up a GPG public key in their profile receive normal email messages. Users with GPG key set receive encrypted messages and if you have signing key also set, the messages are signed with this key.</0>",
|
||||
"doNotUseSensitiveKeysHereThePrivateKey": "<0>Do not use sensitive keys here. The private key and passphrase are not encrypted in the database.</0>",
|
||||
"userMustNotBeEmpty": "User must not be empty",
|
||||
"roleMustBeSelected": "Role must be selected",
|
||||
"role": "Role",
|
||||
"addUser": "Add User",
|
||||
"user": "User",
|
||||
"existingUsers": "Existing Users",
|
||||
"sharesForUserUsername": "Shares for user \"{{username}}\"",
|
||||
"templateSaved": "Template saved",
|
||||
"deletingTemplate": "Deleting template ...",
|
||||
"templateDeleted": "Template deleted",
|
||||
"editTemplate": "Edit Template",
|
||||
"createTemplate": "Create Template",
|
||||
"saveAndEditTemplate": "Save and edit template",
|
||||
"mosaicoTemplates": "Mosaico Templates",
|
||||
"sendConfigurationHasToBeSelected": "Send configuration has to be selected.",
|
||||
"listHasToBeSelected": "List has to be selected.",
|
||||
"mosaico": "Mosaico",
|
||||
"templateContentHtml": "Template content (HTML)",
|
||||
"mosaicoTemplateDesigner": "Mosaico Template Designer",
|
||||
"mosaicoTemplateMustBeSelected": "Mosaico template must be selected",
|
||||
"mosaicoWithPredefinedTemplates": "Mosaico with predefined templates",
|
||||
"mosaicoTemplate-1": "Mosaico Template",
|
||||
"grapesJs": "GrapesJS",
|
||||
"grapesJsTemplateDesigner": "GrapesJS Template Designer",
|
||||
"ckEditor4": "CKEditor 4",
|
||||
"ckEditor4TemplateDesigner": "CKEditor 4 Template Designer",
|
||||
"codeEditor": "Code Editor",
|
||||
"codeEditorTemplateDesigner": "Code Editor Template Designer",
|
||||
"mergeTagReference": "Merge tag reference",
|
||||
"templateContentPlainText": "Template content (plain text)",
|
||||
"mergeTagsAreTagsThatAreReplacedBefore": "\n <1>Merge tags are tags that are replaced before sending out the message. The format of the merge tag is the following: <1>[TAG_NAME]</1> or <3>[TAG_NAME/fallback]</3> where <5>fallback</5> is an optional text value used when <7>TAG_NAME</7> is empty.</1>\n ",
|
||||
"youCanUseAnyOfTheStandardMergeTagsBelow": "\n <1>You can use any of the standard merge tags below. In addition to that every custom field has its own merge tag. Check the fields of the list you are going to send to.</1>\n ",
|
||||
"urlThatPointsToTheUnsubscribePage": "URL that points to the unsubscribe page",
|
||||
"urlThatPointsToThePreferencesPageOfThe": "URL that points to the preferences page of the subscriber",
|
||||
"urlToPreviewTheMessageInABrowser": "URL to preview the message in a browser",
|
||||
"recipientNameAsItAppearsInEmailsToHeader": "Recipient name as it appears in email's 'To' header",
|
||||
"uniqueIdThatIdentifiesTheRecipient": "Unique ID that identifies the recipient",
|
||||
"uniqueIdThatIdentifiesTheListUsedForThis": "Unique ID that identifies the list used for this campaign",
|
||||
"uniqueIdThatIdentifiesCurrentCampaign": "Unique ID that identifies current campaign",
|
||||
"forRssCampaignsTheFollowingFurtherTags": "\n <1>For RSS campaigns, the following further tags can be used.</1>\n ",
|
||||
"rssEntryTitle": "RSS entry title",
|
||||
"rssEntryDate": "RSS entry date",
|
||||
"rssEntryLink": "RSS entry link",
|
||||
"contentOfAnRssEntry": "Content of an RSS entry",
|
||||
"rssEntrySummary": "RSS entry summary",
|
||||
"rssEntryImageUrl": "RSS entry image URL",
|
||||
"toExtractTheTextFromHtmlClickHerePlease": "To extract the text from HTML click <1>here</1>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <3>Premailer API</3>, a third party service. Their Terms of Service and Privacy Policy apply.",
|
||||
"mosaicoTemplateSaved": "Mosaico template saved",
|
||||
"deletingMosaicoTemplate": "Deleting Mosaico template ...",
|
||||
"mosaicoTemplateDeleted": "Mosaico template deleted",
|
||||
"editMosaicoTemplate": "Edit Mosaico Template",
|
||||
"createMosaicoTemplate": "Create Mosaico Template",
|
||||
"blockThumbnails": "Block thumbnails",
|
||||
"versafixOne": "Versafix One",
|
||||
"templateContent": "Template content",
|
||||
"mosaicoTemplateName": "Mosaico Template \"{{name}}\"",
|
||||
"theseFilesArePubliclyAvailableViaHttpSo-1": "These files are publicly available via HTTP so that they can be linked to from the Mosaico template.",
|
||||
"theseFilesWillBeUsedByMosaicoToSearchFor": "These files will be used by Mosaico to search for block thumbnails (the \"edres\" directory). Place here one file per block type that you have defined in the Mosaico template. Each file must have the same name as the block id. The file will be used as the thumbnail of the corresponding block.",
|
||||
"theUserNameAlreadyExistsInTheSystem": "The user name already exists in the system.",
|
||||
"userSaved": "User saved",
|
||||
"theUsernameIsAlreadyAssignedToAnother": "The username is already assigned to another user.",
|
||||
"theEmailIsAlreadyAssignedToAnotherUser-1": "The email is already assigned to another user.",
|
||||
"deletingUser": "Deleting user ...",
|
||||
"userDeleted": "User deleted",
|
||||
"editUser": "Edit User",
|
||||
"createUser": "Create User",
|
||||
"userName": "User Name",
|
||||
"repeatPassword": "Repeat Password",
|
||||
"deleteUser": "Delete User",
|
||||
"userName-1": "User \"{{name}}\"",
|
||||
"shares": "Shares",
|
||||
"subscriptionconfirmed": "Subscription Confirmed",
|
||||
"listEmailAddressAlreadyRegistered": "{{list}}: Email Address Already Registered",
|
||||
"listPleaseConfirmEmailChangeIn": "{{list}}: Please Confirm Email Change in Subscription",
|
||||
"pleaseConfirmSubscription": "Please Confirm Subscription",
|
||||
"listPleaseConfirmUnsubscription": "{{list}}: Please Confirm Unsubscription",
|
||||
"listUnsubscriptionConfirmed": "{{list}}: Unsubscription Confirmed",
|
||||
"invalidEmailAddressEmailMxRecordNotFound": "Invalid email address \"{{email}}\": MX record not found for domain",
|
||||
"invalidEmailAddressEmailAddressDomainNot": "Invalid email address \"{{email}}\": Address domain not found",
|
||||
"invalidEmailAddressEmailAddressDomain": "Invalid email address \"{{email}}\": Address domain name is required",
|
||||
"invalidEmailGeneric": "Invalid email address \"{{email}}\"",
|
||||
"mailerPasswordChangeRequest": "Mailer password change request",
|
||||
"mailtrain": "Mailtrain",
|
||||
"emailAddressChanged": "Email address changed",
|
||||
"emailAddressNotSet": "Email address not set",
|
||||
"nothingSeemsToBeChanged": "Nothing seems to be changed",
|
||||
"anEmailWithFurtherInstructionsHasBeen": "An email with further instructions has been sent to the provided address",
|
||||
"foundAddedMessagesNewCampaignMessages": "Found {{addedMessages}} new campaign messages from feed {{campaignId}}",
|
||||
"foundNothingNewFromTheFeed": "Found nothing new from the feed",
|
||||
"missingEmail": "Missing email",
|
||||
"emailAddress-2": "Email Address",
|
||||
"wantToChangeIt?": "want to change it?",
|
||||
"downloadSignatureVerificationKey": "Download signature verification key",
|
||||
"beginsWithAnd#39BeginPgpPublicKeyBloc": "Begins with '-----BEGIN PGP PUBLIC KEY BLOCK-----'",
|
||||
"insertYourGpgPublicKeyHereToEncrypt": "Insert your GPG public key here to encrypt messages sent to your address <em>(optional)</em>",
|
||||
"warning!": "Warning!",
|
||||
"javaScriptMustBeEnabledInOrderForThis": "JavaScript must be enabled in order for this form to work",
|
||||
"existingEmailAddress": "Existing Email Address",
|
||||
"newEmailAddress": "New Email Address",
|
||||
"youWillReceiveAConfirmationRequestToYour": "You will receive a confirmation request to your new email address that you need to accept before your email is actually changed",
|
||||
"updateEmailAddress": "Update Email Address",
|
||||
"updateProfile": "Update Profile",
|
||||
"subscribeToList": "Subscribe to list",
|
||||
"thePasswordMustBeAtLeastMinLength": "The password must be at least {{ minLength }} characters long",
|
||||
"thePasswordMustBeFewerThanMaxLength": "The password must be fewer than {{ maxLength }} characters",
|
||||
"thePasswordMayNotContainSequencesOfThree": "The password may not contain sequences of three or more repeated characters",
|
||||
"thePasswordMustContainAtLeastOne": "The password must contain at least one lowercase letter",
|
||||
"thePasswordMustContainAtLeastOne-1": "The password must contain at least one uppercase letter",
|
||||
"thePasswordMustContainAtLeastOneNumber": "The password must contain at least one number",
|
||||
"thePasswordMustContainAtLeastOneSpecial": "The password must contain at least one special character"
|
||||
}
|
||||
|
|
@ -1,363 +1,363 @@
|
|||
'use strict';
|
||||
|
||||
// Processes statements like these:
|
||||
// tUI(/*prefix:account*/'account.passwordChangeRequest', language)
|
||||
// /*prefix:helpers*/<Trans i18nKey="userMessagesUnread" count={count}>Hello <strong title={t('nameTitle')}>{{name}}</strong>, you have {{count}} unread message. <Link to="/msgs">Go to messages</Link>.</Trans>
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const klawSync = require('klaw-sync');
|
||||
const acorn = require("acorn");
|
||||
const acornJsx = require("acorn-jsx");
|
||||
const ellipsize = require('ellipsize');
|
||||
const camelCase = require('camelcase');
|
||||
const slugify = require('slugify');
|
||||
const readline = require('readline');
|
||||
|
||||
const localeFile = 'en-US/common.json';
|
||||
const searchDirs = [
|
||||
'../client/src',
|
||||
'../server',
|
||||
'../shared'
|
||||
];
|
||||
|
||||
function findAllVariantsByPrefixInDict(dict, keyPrefix) {
|
||||
const keyElems = keyPrefix.split('.');
|
||||
|
||||
for (const keyElem of keyElems.slice(0, -1)) {
|
||||
if (dict[keyElem]) {
|
||||
if (typeof dict[keyElem] === 'string') {
|
||||
return [];
|
||||
} else {
|
||||
dict = dict[keyElem];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = keyElems[keyElems.length - 1];
|
||||
const res = [];
|
||||
for (const key in dict) {
|
||||
if (key.startsWith(prefix)) {
|
||||
res.push(key.substring(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function findInDict(dict, key) {
|
||||
const keyElems = key.split('.');
|
||||
|
||||
for (const keyElem of keyElems.slice(0, -1)) {
|
||||
if (dict[keyElem]) {
|
||||
if (typeof dict[keyElem] === 'string') {
|
||||
return undefined;
|
||||
} else {
|
||||
dict = dict[keyElem];
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return dict[keyElems[keyElems.length - 1]];
|
||||
}
|
||||
|
||||
function setInDict(dict, key, value) {
|
||||
const keyElems = key.split('.');
|
||||
|
||||
for (const keyElem of keyElems.slice(0, -1)) {
|
||||
if (dict[keyElem]) {
|
||||
if (typeof dict[keyElem] === 'string') {
|
||||
throw new Error(`Overlapping key ${key}`);
|
||||
}
|
||||
} else {
|
||||
dict[keyElem] = {}
|
||||
}
|
||||
|
||||
dict = dict[keyElem];
|
||||
}
|
||||
|
||||
dict[keyElems[keyElems.length - 1]] = value;
|
||||
}
|
||||
|
||||
const assignedKeys = new Map();
|
||||
function getKeyFromValue(spec, value) {
|
||||
let key = value.replace(/<\/?[0-9]+>/g, ''); // Remove Trans markup
|
||||
key = slugify(key, { replacement: ' ', remove: /[\\()"':.,;\/\[\]\{\}*+-]/g, lower: false });
|
||||
key = camelCase(key);
|
||||
key = ellipsize(key, 40, {
|
||||
chars: [...Array(26)].map((_, i) => String.fromCharCode('A'.charCodeAt(0) + i)) /* This is an array of characters A-Z */,
|
||||
ellipse: ''
|
||||
});
|
||||
|
||||
if (spec.prefix) {
|
||||
key = spec.prefix + '.' + key;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
const keyExt = key + (idx ? '-' + idx : '')
|
||||
if (assignedKeys.has(keyExt)) {
|
||||
if (assignedKeys.get(keyExt) === value) {
|
||||
return keyExt;
|
||||
}
|
||||
} else {
|
||||
assignedKeys.set(keyExt, value);
|
||||
return keyExt;
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
function allowedDirOrFile(item) {
|
||||
const pp = path.parse(item.path)
|
||||
|
||||
return (
|
||||
(item.stats.isDirectory() &&
|
||||
pp.base !== 'node_modules'
|
||||
) ||
|
||||
(item.stats.isFile() &&
|
||||
( pp.ext === '.js' || pp.ext === '.jsx' || pp.ext === '.hbs')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function parseSpec(specStr) {
|
||||
const spec = {};
|
||||
|
||||
if (specStr) {
|
||||
const entryMatcher = /([a-zA-Z]*)\s*:\s*(.*)/
|
||||
|
||||
const entries = specStr.split(/\s*,\s*/);
|
||||
for (const entry of entries) {
|
||||
const elems = entry.match(entryMatcher);
|
||||
if (elems) {
|
||||
spec[elems[1]] = elems[2];
|
||||
} else {
|
||||
spec[entry] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// see http://blog.stevenlevithan.com/archives/match-quoted-string
|
||||
const tMatcher = /(^|[ {+(=.\[])((?:tUI|tLog|t|tMark)\s*\(\s*(?:\/\*(.*?)\*\/)?\s*)(["'])((?:(?!\4)[^\\]|\\.)*)(\4)/;
|
||||
const jsxTransMatcher = /(\/\*(.*?)\*\/\s*)?(\<Trans[ >][\s\S]*?\<\/Trans\>)/;
|
||||
const hbsTranslateMatcher = /(\{\{!--(.*?)--\}\}\s*)?(\{\{#translate\}\})([\s\S]*?)(\{\{\/translate\}\})/;
|
||||
|
||||
const jsxParser = acorn.Parser.extend(acornJsx());
|
||||
function parseJsxTrans(fragment) {
|
||||
const match = fragment.match(jsxTransMatcher);
|
||||
const spec = parseSpec(match[2]);
|
||||
const jsxStr = match[3];
|
||||
|
||||
const jsxStrSmpl = jsxStr.replace('{::', '{ '); // Acorn does not handle bind (::) operator. So we just leave it out because we are not interested in the code anyway.
|
||||
const ast = jsxParser.parse(jsxStrSmpl);
|
||||
|
||||
function convertChildren(children) {
|
||||
const entries = [];
|
||||
let childNo = 0;
|
||||
|
||||
for (const child of children) {
|
||||
const type = child.type;
|
||||
|
||||
if (type === 'JSXText') {
|
||||
entries.push(child.value);
|
||||
childNo++;
|
||||
|
||||
} else if (type === 'JSXElement') {
|
||||
const inner = convertChildren(child.children);
|
||||
entries.push(`<${childNo}>${convertChildren(child.children)}</${childNo}>`);
|
||||
childNo++;
|
||||
|
||||
} else if (type === 'JSXExpressionContainer') {
|
||||
entries.push(jsxStr.substring(child.start, child.end));
|
||||
childNo++;
|
||||
|
||||
} else {
|
||||
throw new Error('Unknown JSX node: ' + child);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.join('');
|
||||
}
|
||||
|
||||
const expr = ast.body[0].expression;
|
||||
|
||||
let originalKey;
|
||||
for (const attr of expr.openingElement.attributes) {
|
||||
const name = attr.name.name;
|
||||
if (name === 'i18nKey') {
|
||||
originalKey = attr.value.value;
|
||||
}
|
||||
}
|
||||
|
||||
const convValue = convertChildren(expr.children);
|
||||
|
||||
if (originalKey === undefined) {
|
||||
originalKey = convValue;
|
||||
}
|
||||
|
||||
let value;
|
||||
const originalValue = findInDict(originalResDict, originalKey);
|
||||
|
||||
if (originalValue === undefined) {
|
||||
value = convValue;
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
|
||||
const key = getKeyFromValue(spec, value);
|
||||
|
||||
const replacement = `${match[1] || ''}<Trans i18nKey="${key}">${jsxStr.substring(expr.openingElement.end, expr.closingElement.start)}</Trans>`;
|
||||
|
||||
return { key, originalKey, value, replacement };
|
||||
}
|
||||
|
||||
|
||||
function parseHbsTranslate(fragment) {
|
||||
const match = fragment.match(hbsTranslateMatcher);
|
||||
const spec = parseSpec(match[2]);
|
||||
const originalKey = match[4];
|
||||
|
||||
let value;
|
||||
const originalValue = findInDict(originalResDict, originalKey);
|
||||
|
||||
if (originalValue === undefined) {
|
||||
value = originalKey;
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
|
||||
const key = getKeyFromValue(spec, value);
|
||||
|
||||
const replacement = `${match[1] || ''}${match[3]}${key}${match[5]}`;
|
||||
|
||||
return { key, originalKey, value, replacement };
|
||||
}
|
||||
|
||||
function parseT(fragment) {
|
||||
const match = fragment.match(tMatcher);
|
||||
|
||||
const originalKey = match[5];
|
||||
const spec = parseSpec(match[3]);
|
||||
|
||||
if (spec.ignore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// console.log(`${fragment}`);
|
||||
// console.log(` |${match[1]}|${match[2]}|${match[4]}|${match[5]}|${match[6]}| - ${JSON.stringify(spec)}`);
|
||||
|
||||
let value;
|
||||
const originalValue = findInDict(originalResDict, originalKey);
|
||||
|
||||
if (originalValue === undefined) {
|
||||
value = originalKey;
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
|
||||
const key = getKeyFromValue(spec, value);
|
||||
|
||||
const replacement = `${match[1]}${match[2]}${match[4]}${key}${match[6]}`;
|
||||
|
||||
return { key, originalKey, value, originalValue, replacement };
|
||||
}
|
||||
|
||||
const renamedKeys = new Map();
|
||||
const resDict = {};
|
||||
let anyUpdatesToResDict = false;
|
||||
|
||||
function processFile(file) {
|
||||
let source = fs.readFileSync(file, 'utf8');
|
||||
let anyUpdates = false;
|
||||
|
||||
function update(fragments, parseFun) {
|
||||
if (fragments) {
|
||||
for (const fragment of fragments) {
|
||||
const parseStruct = parseFun(fragment);
|
||||
if (parseStruct) {
|
||||
const {key, originalKey, value, originalValue, replacement} = parseStruct;
|
||||
// console.log(`${key} <- ${originalKey} | ${value} <- ${originalValue} | ${fragment} -> ${replacement}`);
|
||||
|
||||
source = source.split(fragment).join(replacement);
|
||||
setInDict(resDict, key, value);
|
||||
|
||||
const variants = originalKey ? findAllVariantsByPrefixInDict(originalResDict, originalKey + '_') : [];
|
||||
for (const variant of variants) {
|
||||
setInDict(resDict, key + '_' + variant, findInDict(originalResDict, originalKey + '_' + variant));
|
||||
}
|
||||
|
||||
if (originalKey !== key) {
|
||||
renamedKeys.set(originalKey, key);
|
||||
|
||||
for (const variant of variants) {
|
||||
renamedKeys.set(originalKey + '_' + variant, key + '_' + variant);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalKey !== key || originalValue !== value) {
|
||||
anyUpdates = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines = source.split(/\r?\n/g);
|
||||
for (const line of lines) {
|
||||
const fragments = line.match(new RegExp(tMatcher, 'g'));
|
||||
update(fragments, parseT);
|
||||
}
|
||||
|
||||
const hbsFragments = source.match(new RegExp(hbsTranslateMatcher, 'g'));
|
||||
update(hbsFragments, parseHbsTranslate);
|
||||
|
||||
const jsxFragments = source.match(new RegExp(jsxTransMatcher, 'g'));
|
||||
update(jsxFragments, parseJsxTrans);
|
||||
|
||||
if (anyUpdates) {
|
||||
console.log(`Updating ${file}`);
|
||||
fs.writeFileSync(file, source);
|
||||
|
||||
anyUpdatesToResDict = true;
|
||||
}
|
||||
}
|
||||
|
||||
const originalResDict = JSON.parse(fs.readFileSync(localeFile));
|
||||
|
||||
function run() {
|
||||
for (const dir of searchDirs) {
|
||||
const files = klawSync(dir, { nodir: true, filter: allowedDirOrFile })
|
||||
|
||||
for (const file of files) {
|
||||
processFile(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyUpdatesToResDict) {
|
||||
console.log(`Updating ${localeFile}`);
|
||||
fs.writeFileSync(localeFile, JSON.stringify(resDict, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
console.log('This script does modifications in the source tree. You should first commit all your files in git before proceeding.');
|
||||
rl.question('To proceed type YES: ', (answer) => {
|
||||
if (answer === 'YES') {
|
||||
run();
|
||||
}
|
||||
|
||||
rl.close();
|
||||
});
|
||||
'use strict';
|
||||
|
||||
// Processes statements like these:
|
||||
// tUI(/*prefix:account*/'account.passwordChangeRequest', language)
|
||||
// /*prefix:helpers*/<Trans i18nKey="userMessagesUnread" count={count}>Hello <strong title={t('nameTitle')}>{{name}}</strong>, you have {{count}} unread message. <Link to="/msgs">Go to messages</Link>.</Trans>
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const klawSync = require('klaw-sync');
|
||||
const acorn = require("acorn");
|
||||
const acornJsx = require("acorn-jsx");
|
||||
const ellipsize = require('ellipsize');
|
||||
const camelCase = require('camelcase');
|
||||
const slugify = require('slugify');
|
||||
const readline = require('readline');
|
||||
|
||||
const localeFile = 'en-US/common.json';
|
||||
const searchDirs = [
|
||||
'../client/src',
|
||||
'../server',
|
||||
'../shared'
|
||||
];
|
||||
|
||||
function findAllVariantsByPrefixInDict(dict, keyPrefix) {
|
||||
const keyElems = keyPrefix.split('.');
|
||||
|
||||
for (const keyElem of keyElems.slice(0, -1)) {
|
||||
if (dict[keyElem]) {
|
||||
if (typeof dict[keyElem] === 'string') {
|
||||
return [];
|
||||
} else {
|
||||
dict = dict[keyElem];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = keyElems[keyElems.length - 1];
|
||||
const res = [];
|
||||
for (const key in dict) {
|
||||
if (key.startsWith(prefix)) {
|
||||
res.push(key.substring(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function findInDict(dict, key) {
|
||||
const keyElems = key.split('.');
|
||||
|
||||
for (const keyElem of keyElems.slice(0, -1)) {
|
||||
if (dict[keyElem]) {
|
||||
if (typeof dict[keyElem] === 'string') {
|
||||
return undefined;
|
||||
} else {
|
||||
dict = dict[keyElem];
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return dict[keyElems[keyElems.length - 1]];
|
||||
}
|
||||
|
||||
function setInDict(dict, key, value) {
|
||||
const keyElems = key.split('.');
|
||||
|
||||
for (const keyElem of keyElems.slice(0, -1)) {
|
||||
if (dict[keyElem]) {
|
||||
if (typeof dict[keyElem] === 'string') {
|
||||
throw new Error(`Overlapping key ${key}`);
|
||||
}
|
||||
} else {
|
||||
dict[keyElem] = {}
|
||||
}
|
||||
|
||||
dict = dict[keyElem];
|
||||
}
|
||||
|
||||
dict[keyElems[keyElems.length - 1]] = value;
|
||||
}
|
||||
|
||||
const assignedKeys = new Map();
|
||||
function getKeyFromValue(spec, value) {
|
||||
let key = value.replace(/<\/?[0-9]+>/g, ''); // Remove Trans markup
|
||||
key = slugify(key, { replacement: ' ', remove: /[\\()"':.,;\/\[\]\{\}*+-]/g, lower: false });
|
||||
key = camelCase(key);
|
||||
key = ellipsize(key, 40, {
|
||||
chars: [...Array(26)].map((_, i) => String.fromCharCode('A'.charCodeAt(0) + i)) /* This is an array of characters A-Z */,
|
||||
ellipse: ''
|
||||
});
|
||||
|
||||
if (spec.prefix) {
|
||||
key = spec.prefix + '.' + key;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
const keyExt = key + (idx ? '-' + idx : '')
|
||||
if (assignedKeys.has(keyExt)) {
|
||||
if (assignedKeys.get(keyExt) === value) {
|
||||
return keyExt;
|
||||
}
|
||||
} else {
|
||||
assignedKeys.set(keyExt, value);
|
||||
return keyExt;
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
function allowedDirOrFile(item) {
|
||||
const pp = path.parse(item.path)
|
||||
|
||||
return (
|
||||
(item.stats.isDirectory() &&
|
||||
pp.base !== 'node_modules'
|
||||
) ||
|
||||
(item.stats.isFile() &&
|
||||
( pp.ext === '.js' || pp.ext === '.jsx' || pp.ext === '.hbs')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function parseSpec(specStr) {
|
||||
const spec = {};
|
||||
|
||||
if (specStr) {
|
||||
const entryMatcher = /([a-zA-Z]*)\s*:\s*(.*)/
|
||||
|
||||
const entries = specStr.split(/\s*,\s*/);
|
||||
for (const entry of entries) {
|
||||
const elems = entry.match(entryMatcher);
|
||||
if (elems) {
|
||||
spec[elems[1]] = elems[2];
|
||||
} else {
|
||||
spec[entry] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// see http://blog.stevenlevithan.com/archives/match-quoted-string
|
||||
const tMatcher = /(^|[ {+(=.\[])((?:tUI|tLog|t|tMark)\s*\(\s*(?:\/\*(.*?)\*\/)?\s*)(["'])((?:(?!\4)[^\\]|\\.)*)(\4)/;
|
||||
const jsxTransMatcher = /(\/\*(.*?)\*\/\s*)?(\<Trans[ >][\s\S]*?\<\/Trans\>)/;
|
||||
const hbsTranslateMatcher = /(\{\{!--(.*?)--\}\}\s*)?(\{\{#translate\}\})([\s\S]*?)(\{\{\/translate\}\})/;
|
||||
|
||||
const jsxParser = acorn.Parser.extend(acornJsx());
|
||||
function parseJsxTrans(fragment) {
|
||||
const match = fragment.match(jsxTransMatcher);
|
||||
const spec = parseSpec(match[2]);
|
||||
const jsxStr = match[3];
|
||||
|
||||
const jsxStrSmpl = jsxStr.replace('{::', '{ '); // Acorn does not handle bind (::) operator. So we just leave it out because we are not interested in the code anyway.
|
||||
const ast = jsxParser.parse(jsxStrSmpl);
|
||||
|
||||
function convertChildren(children) {
|
||||
const entries = [];
|
||||
let childNo = 0;
|
||||
|
||||
for (const child of children) {
|
||||
const type = child.type;
|
||||
|
||||
if (type === 'JSXText') {
|
||||
entries.push(child.value);
|
||||
childNo++;
|
||||
|
||||
} else if (type === 'JSXElement') {
|
||||
const inner = convertChildren(child.children);
|
||||
entries.push(`<${childNo}>${convertChildren(child.children)}</${childNo}>`);
|
||||
childNo++;
|
||||
|
||||
} else if (type === 'JSXExpressionContainer') {
|
||||
entries.push(jsxStr.substring(child.start, child.end));
|
||||
childNo++;
|
||||
|
||||
} else {
|
||||
throw new Error('Unknown JSX node: ' + child);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.join('');
|
||||
}
|
||||
|
||||
const expr = ast.body[0].expression;
|
||||
|
||||
let originalKey;
|
||||
for (const attr of expr.openingElement.attributes) {
|
||||
const name = attr.name.name;
|
||||
if (name === 'i18nKey') {
|
||||
originalKey = attr.value.value;
|
||||
}
|
||||
}
|
||||
|
||||
const convValue = convertChildren(expr.children);
|
||||
|
||||
if (originalKey === undefined) {
|
||||
originalKey = convValue;
|
||||
}
|
||||
|
||||
let value;
|
||||
const originalValue = findInDict(originalResDict, originalKey);
|
||||
|
||||
if (originalValue === undefined) {
|
||||
value = convValue;
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
|
||||
const key = getKeyFromValue(spec, value);
|
||||
|
||||
const replacement = `${match[1] || ''}<Trans i18nKey="${key}">${jsxStr.substring(expr.openingElement.end, expr.closingElement.start)}</Trans>`;
|
||||
|
||||
return { key, originalKey, value, replacement };
|
||||
}
|
||||
|
||||
|
||||
function parseHbsTranslate(fragment) {
|
||||
const match = fragment.match(hbsTranslateMatcher);
|
||||
const spec = parseSpec(match[2]);
|
||||
const originalKey = match[4];
|
||||
|
||||
let value;
|
||||
const originalValue = findInDict(originalResDict, originalKey);
|
||||
|
||||
if (originalValue === undefined) {
|
||||
value = originalKey;
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
|
||||
const key = getKeyFromValue(spec, value);
|
||||
|
||||
const replacement = `${match[1] || ''}${match[3]}${key}${match[5]}`;
|
||||
|
||||
return { key, originalKey, value, replacement };
|
||||
}
|
||||
|
||||
function parseT(fragment) {
|
||||
const match = fragment.match(tMatcher);
|
||||
|
||||
const originalKey = match[5];
|
||||
const spec = parseSpec(match[3]);
|
||||
|
||||
if (spec.ignore) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// console.log(`${fragment}`);
|
||||
// console.log(` |${match[1]}|${match[2]}|${match[4]}|${match[5]}|${match[6]}| - ${JSON.stringify(spec)}`);
|
||||
|
||||
let value;
|
||||
const originalValue = findInDict(originalResDict, originalKey);
|
||||
|
||||
if (originalValue === undefined) {
|
||||
value = originalKey;
|
||||
} else {
|
||||
value = originalValue;
|
||||
}
|
||||
|
||||
const key = getKeyFromValue(spec, value);
|
||||
|
||||
const replacement = `${match[1]}${match[2]}${match[4]}${key}${match[6]}`;
|
||||
|
||||
return { key, originalKey, value, originalValue, replacement };
|
||||
}
|
||||
|
||||
const renamedKeys = new Map();
|
||||
const resDict = {};
|
||||
let anyUpdatesToResDict = false;
|
||||
|
||||
function processFile(file) {
|
||||
let source = fs.readFileSync(file, 'utf8');
|
||||
let anyUpdates = false;
|
||||
|
||||
function update(fragments, parseFun) {
|
||||
if (fragments) {
|
||||
for (const fragment of fragments) {
|
||||
const parseStruct = parseFun(fragment);
|
||||
if (parseStruct) {
|
||||
const {key, originalKey, value, originalValue, replacement} = parseStruct;
|
||||
// console.log(`${key} <- ${originalKey} | ${value} <- ${originalValue} | ${fragment} -> ${replacement}`);
|
||||
|
||||
source = source.split(fragment).join(replacement);
|
||||
setInDict(resDict, key, value);
|
||||
|
||||
const variants = originalKey ? findAllVariantsByPrefixInDict(originalResDict, originalKey + '_') : [];
|
||||
for (const variant of variants) {
|
||||
setInDict(resDict, key + '_' + variant, findInDict(originalResDict, originalKey + '_' + variant));
|
||||
}
|
||||
|
||||
if (originalKey !== key) {
|
||||
renamedKeys.set(originalKey, key);
|
||||
|
||||
for (const variant of variants) {
|
||||
renamedKeys.set(originalKey + '_' + variant, key + '_' + variant);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalKey !== key || originalValue !== value) {
|
||||
anyUpdates = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines = source.split(/\r?\n/g);
|
||||
for (const line of lines) {
|
||||
const fragments = line.match(new RegExp(tMatcher, 'g'));
|
||||
update(fragments, parseT);
|
||||
}
|
||||
|
||||
const hbsFragments = source.match(new RegExp(hbsTranslateMatcher, 'g'));
|
||||
update(hbsFragments, parseHbsTranslate);
|
||||
|
||||
const jsxFragments = source.match(new RegExp(jsxTransMatcher, 'g'));
|
||||
update(jsxFragments, parseJsxTrans);
|
||||
|
||||
if (anyUpdates) {
|
||||
console.log(`Updating ${file}`);
|
||||
fs.writeFileSync(file, source);
|
||||
|
||||
anyUpdatesToResDict = true;
|
||||
}
|
||||
}
|
||||
|
||||
const originalResDict = JSON.parse(fs.readFileSync(localeFile));
|
||||
|
||||
function run() {
|
||||
for (const dir of searchDirs) {
|
||||
const files = klawSync(dir, { nodir: true, filter: allowedDirOrFile })
|
||||
|
||||
for (const file of files) {
|
||||
processFile(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
if (anyUpdatesToResDict) {
|
||||
console.log(`Updating ${localeFile}`);
|
||||
fs.writeFileSync(localeFile, JSON.stringify(resDict, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
console.log('This script does modifications in the source tree. You should first commit all your files in git before proceeding.');
|
||||
rl.question('To proceed type YES: ', (answer) => {
|
||||
if (answer === 'YES') {
|
||||
run();
|
||||
}
|
||||
|
||||
rl.close();
|
||||
});
|
||||
|
|
2
mvis/client/.gitignore
vendored
2
mvis/client/.gitignore
vendored
|
@ -1,2 +1,2 @@
|
|||
/dist
|
||||
/dist
|
||||
/node_modules
|
13
mvis/client/package-lock.json
generated
13
mvis/client/package-lock.json
generated
|
@ -3887,7 +3887,8 @@
|
|||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
|
@ -4302,7 +4303,8 @@
|
|||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
|
@ -4358,6 +4360,7 @@
|
|||
"version": "3.0.1",
|
||||
"bundled": true,
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
|
@ -4401,12 +4404,14 @@
|
|||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"bundled": true,
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit a9ad4bab17475ab8646a0294338df59aa3864cb9
|
||||
Subproject commit 4d53d4b030273f25062fcc3c2328987d5a39cffc
|
4
mvis/server/.gitignore
vendored
4
mvis/server/.gitignore
vendored
|
@ -1,2 +1,2 @@
|
|||
/node_modules
|
||||
/config/development.yaml
|
||||
/node_modules
|
||||
/config/development.yaml
|
||||
|
|
|
@ -1,77 +1,77 @@
|
|||
mysql:
|
||||
host: localhost
|
||||
user: mvis
|
||||
password: mvis
|
||||
database: mvis
|
||||
|
||||
mailtrain:
|
||||
url: http://localhost:3000/
|
||||
namespaces:
|
||||
campaigns: 2
|
||||
userRole: mailtrainUser
|
||||
|
||||
www:
|
||||
# HTTP interface to listen on
|
||||
host: 127.0.0.1
|
||||
# HTTP(S) port to listen on
|
||||
trustedPort: 3010
|
||||
trustedPortIsHttps: false
|
||||
sandboxPort: 3011
|
||||
sandboxPortIsHttps: false
|
||||
apiPort: 3012
|
||||
apiPortIsHttps: false
|
||||
|
||||
trustedUrlBase: http://localhost:3010
|
||||
sandboxUrlBase: http://localhost:3011
|
||||
|
||||
|
||||
roles:
|
||||
global:
|
||||
mailtrainUser:
|
||||
name: "Mailtrain User"
|
||||
admin: true
|
||||
description: "Limited permissions that allow only read-only access"
|
||||
permissions:
|
||||
|
||||
namespace:
|
||||
mailtrainUser:
|
||||
name: "Mailtrain User"
|
||||
description: "Limited permissions that allow only read-only access"
|
||||
permissions: ["view"]
|
||||
children:
|
||||
namespace: ["view"]
|
||||
template: ["view", "viewFiles", "execute"]
|
||||
workspace: ["view"]
|
||||
panel: ["view"]
|
||||
signal: ["view", "query"]
|
||||
signalSet: ["view", "query"]
|
||||
|
||||
# template:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view", "viewFiles", "execute"]
|
||||
#
|
||||
# workspace:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view"]
|
||||
#
|
||||
# panel:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view"]
|
||||
#
|
||||
# signal:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view", "query"]
|
||||
#
|
||||
# signalSet:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view", "query"]
|
||||
mysql:
|
||||
host: localhost
|
||||
user: mvis
|
||||
password: mvis
|
||||
database: mvis
|
||||
|
||||
mailtrain:
|
||||
url: http://localhost:3000/
|
||||
namespaces:
|
||||
campaigns: 2
|
||||
userRole: mailtrainUser
|
||||
|
||||
www:
|
||||
# HTTP interface to listen on
|
||||
host: 127.0.0.1
|
||||
# HTTP(S) port to listen on
|
||||
trustedPort: 3010
|
||||
trustedPortIsHttps: false
|
||||
sandboxPort: 3011
|
||||
sandboxPortIsHttps: false
|
||||
apiPort: 3012
|
||||
apiPortIsHttps: false
|
||||
|
||||
trustedUrlBase: http://localhost:3010
|
||||
sandboxUrlBase: http://localhost:3011
|
||||
|
||||
|
||||
roles:
|
||||
global:
|
||||
mailtrainUser:
|
||||
name: "Mailtrain User"
|
||||
admin: true
|
||||
description: "Limited permissions that allow only read-only access"
|
||||
permissions:
|
||||
|
||||
namespace:
|
||||
mailtrainUser:
|
||||
name: "Mailtrain User"
|
||||
description: "Limited permissions that allow only read-only access"
|
||||
permissions: ["view"]
|
||||
children:
|
||||
namespace: ["view"]
|
||||
template: ["view", "viewFiles", "execute"]
|
||||
workspace: ["view"]
|
||||
panel: ["view"]
|
||||
signal: ["view", "query"]
|
||||
signalSet: ["view", "query"]
|
||||
|
||||
# template:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view", "viewFiles", "execute"]
|
||||
#
|
||||
# workspace:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view"]
|
||||
#
|
||||
# panel:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view"]
|
||||
#
|
||||
# signal:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view", "query"]
|
||||
#
|
||||
# signalSet:
|
||||
# mailtrainUser:
|
||||
# name: "Mailtrain User"
|
||||
# description: "Limited permissions that allow only read-only access"
|
||||
# permissions: ["view", "query"]
|
||||
|
|
4
mvis/test-embed/.gitignore
vendored
4
mvis/test-embed/.gitignore
vendored
|
@ -1,2 +1,2 @@
|
|||
/dist
|
||||
/node_modules
|
||||
/dist
|
||||
/node_modules
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<div id="evif-panel" />
|
||||
|
||||
<script type="text/javascript" src="/ivis.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
IVIS.embedPanel('evif-panel', '{{ivisSandboxUrlBase}}', {{panelId}}, '{{token}}');
|
||||
</script>
|
||||
|
||||
<div id="evif-panel" />
|
||||
|
||||
<script type="text/javascript" src="/ivis.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
IVIS.embedPanel('evif-panel', '{{ivisSandboxUrlBase}}', {{panelId}}, '{{token}}');
|
||||
</script>
|
||||
|
||||
|
|
|
@ -320,93 +320,49 @@ function createApp(appType) {
|
|||
|
||||
app.use('/', index.getRouter(appType));
|
||||
|
||||
// Error handlers
|
||||
if (app.get('env') === 'development' || app.get('env') === 'test') {
|
||||
// development error handler
|
||||
// will print stacktrace
|
||||
app.use((err, req, res, next) => {
|
||||
if (!err) {
|
||||
return next();
|
||||
app.use((err, req, res, next) => {
|
||||
if (!err) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.needsRESTJSONResponse) {
|
||||
const resp = {
|
||||
message: err.message,
|
||||
error: config.sendStacktracesToClient ? err : {}
|
||||
};
|
||||
|
||||
if (err instanceof interoperableErrors.InteroperableError) {
|
||||
resp.type = err.type;
|
||||
resp.data = err.data;
|
||||
}
|
||||
|
||||
if (req.needsRESTJSONResponse) {
|
||||
const resp = {
|
||||
message: err.message,
|
||||
error: err
|
||||
};
|
||||
log.verbose('HTTP', err);
|
||||
res.status(err.status || 500).json(resp);
|
||||
|
||||
if (err instanceof interoperableErrors.InteroperableError) {
|
||||
resp.type = err.type;
|
||||
resp.data = err.data;
|
||||
}
|
||||
} else if (req.needsAPIJSONResponse) {
|
||||
const resp = {
|
||||
error: err.message || err,
|
||||
data: []
|
||||
};
|
||||
|
||||
res.status(err.status || 500).json(resp);
|
||||
log.verbose('HTTP', err);
|
||||
return res.status(err.status || 500).json(resp);
|
||||
|
||||
} else if (req.needsAPIJSONResponse) {
|
||||
const resp = {
|
||||
error: err.message || err,
|
||||
data: []
|
||||
};
|
||||
|
||||
return res.status(err.status || 500).json(resp);
|
||||
} else {
|
||||
// TODO: Render interoperable errors using a special client that does internationalization of the error message
|
||||
|
||||
if (err instanceof interoperableErrors.NotLoggedInError) {
|
||||
return res.redirect(getTrustedUrl('/login?next=' + encodeURIComponent(req.originalUrl)));
|
||||
} else {
|
||||
if (err instanceof interoperableErrors.NotLoggedInError) {
|
||||
return res.redirect(getTrustedUrl('/login?next=' + encodeURIComponent(req.originalUrl)));
|
||||
} else {
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: err
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
} else {
|
||||
// production error handler
|
||||
// no stacktraces leaked to user
|
||||
app.use((err, req, res, next) => {
|
||||
if (!err) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (req.needsRESTJSONResponse) {
|
||||
const resp = {
|
||||
log.verbose('HTTP', err);
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: {}
|
||||
};
|
||||
|
||||
if (err instanceof interoperableErrors.InteroperableError) {
|
||||
resp.type = err.type;
|
||||
resp.data = err.data;
|
||||
}
|
||||
|
||||
res.status(err.status || 500).json(resp);
|
||||
|
||||
} else if (req.needsAPIJSONResponse) {
|
||||
const resp = {
|
||||
error: err.message || err,
|
||||
data: []
|
||||
};
|
||||
|
||||
return res.status(err.status || 500).json(resp);
|
||||
|
||||
} else {
|
||||
// TODO: Render interoperable errors using a special client that does internationalization of the error message
|
||||
|
||||
if (err instanceof interoperableErrors.NotLoggedInError) {
|
||||
return res.redirect(getTrustedUrl('/login?next=' + encodeURIComponent(req.originalUrl)));
|
||||
} else {
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: {}
|
||||
});
|
||||
}
|
||||
error: config.sendStacktracesToClient ? err : {}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
|
|
@ -42,6 +42,7 @@ defaultLanguage: en-US
|
|||
# Enabled languages
|
||||
enabledLanguages:
|
||||
- en-US
|
||||
- es-ES
|
||||
- fk-FK
|
||||
|
||||
# Inject custom scripts in subscription/layout.mjml.hbs
|
||||
|
@ -72,6 +73,7 @@ redis:
|
|||
log:
|
||||
# silly|verbose|info|http|warn|error|silent
|
||||
level: info
|
||||
sendStacktracesToClient: false
|
||||
|
||||
www:
|
||||
# HTTP port to listen on for trusted requests (logged-in users)
|
||||
|
@ -217,6 +219,8 @@ builtinZoneMTA:
|
|||
redis: redis://localhost:6379/2
|
||||
log:
|
||||
level: warn
|
||||
processes: 2
|
||||
connections: 5
|
||||
|
||||
seleniumWebDriver:
|
||||
browser: phantomjs
|
||||
|
@ -228,7 +232,7 @@ roles:
|
|||
name: Global Master
|
||||
admin: true
|
||||
description: All permissions
|
||||
permissions: [rebuildPermissions, createJavascriptWithROAccess, manageBlacklist, manageSettings, setupAutomation]
|
||||
permissions: [rebuildPermissions, createJavascriptWithROAccess, displayManageUsers, manageBlacklist, manageSettings, setupAutomation]
|
||||
rootNamespaceRole: master
|
||||
campaignsAdmin:
|
||||
name: Campaigns Admin
|
||||
|
|
|
@ -24,6 +24,7 @@ const { AppType } = require('../shared/app');
|
|||
const builtinZoneMta = require('./lib/builtin-zone-mta');
|
||||
|
||||
const { uploadedFilesDir } = require('./lib/file-helpers');
|
||||
const { filesDir } = require('./models/files');
|
||||
|
||||
const trustedPort = config.www.trustedPort;
|
||||
const sandboxPort = config.www.sandboxPort;
|
||||
|
@ -113,6 +114,7 @@ dbcheck(err => { // Check if database needs upgrading before starting the server
|
|||
startHTTPServer(AppType.SANDBOXED, 'sandbox', sandboxPort, () =>
|
||||
startHTTPServer(AppType.PUBLIC, 'public', publicPort, async () => {
|
||||
|
||||
await privilegeHelpers.ensureMailtrainDir(filesDir);
|
||||
await privilegeHelpers.ensureMailtrainDir(uploadedFilesDir);
|
||||
|
||||
privilegeHelpers.dropRootPrivileges();
|
||||
|
|
54
server/lib/activity-log.js
Normal file
54
server/lib/activity-log.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
async function _logActivity(typeId, data) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
/*
|
||||
Extra data:
|
||||
|
||||
campaign:
|
||||
- status : CampaignStatus
|
||||
|
||||
list:
|
||||
- subscriptionId
|
||||
- subscriptionStatus : SubscriptionStatus
|
||||
- fieldId
|
||||
- segmentId
|
||||
- importId
|
||||
- importStatus : ImportStatus
|
||||
*/
|
||||
async function logEntityActivity(entityTypeId, activityType, entityId, extraData = {}) {
|
||||
const data = {
|
||||
...extraData,
|
||||
type: activityType,
|
||||
entity: entityId
|
||||
};
|
||||
|
||||
await _logActivity(entityTypeId, data);
|
||||
}
|
||||
|
||||
async function logCampaignTrackerActivity(activityType, campaignId, listId, subscriptionId, extraData = {}) {
|
||||
const data = {
|
||||
...extraData,
|
||||
type: activityType,
|
||||
campaign: campaignId,
|
||||
list: listId,
|
||||
subscription: subscriptionId
|
||||
};
|
||||
|
||||
await _logActivity('campaign_tracker', data);
|
||||
}
|
||||
|
||||
async function logBlacklistActivity(activityType, email) {
|
||||
const data = {
|
||||
type: activityType,
|
||||
email
|
||||
};
|
||||
|
||||
await _logActivity('blacklist', data);
|
||||
}
|
||||
|
||||
module.exports.logEntityActivity = logEntityActivity;
|
||||
module.exports.logBlacklistActivity = logBlacklistActivity;
|
||||
module.exports.logCampaignTrackerActivity = logCampaignTrackerActivity;
|
|
@ -108,8 +108,8 @@ async function createConfig() {
|
|||
default: {
|
||||
preferIPv6: false,
|
||||
ignoreIPv6: true,
|
||||
processes: 1,
|
||||
connections: 5,
|
||||
processes: config.builtinZoneMTA.processes,
|
||||
connections: config.builtinZoneMTA.connections,
|
||||
pool: 'default'
|
||||
}
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue