Wired up newsletter management with real newsletter model and API

refs https://github.com/TryGhost/Team/issues/1441

- switched "leave settings" confirmation modal on members email settings screen over to modern modal pattern
- removed unused `showEmailDesignSettings` property and `closeEmailDesignSettings()` action on `<Settings::MembersEmailLabs>` component
  - the used property and action live on the controller, looks like it was a copy/paste hangover when functionality was moved to a component
- added newsletter model
  - includes design-related attributes which are not yet supported by the API but are due to be added
  - includes `default` attribute but there is no setting for it, due to be removed from the API but it's needed for save not to error for now
  - set up basic mirage model and endpoints
  - added validation for main settings to match API validation
- added `EditNewsletter` modal
  - separate tabs for general newsletter settings and design-related settings
  - used for both creating and editing newsletters
- added `/settings/members-email/newsletters/new` and `/settings/members-email/newsletters/:id` routes
  - both display the `EditNewsletter` modal on top of the members-email settings screen with the appropriate newsletter model
- updated `<Settings::MembersEmailLabs::NewsletterManagement>` component to work with real newsletter model instances and the new add/edit routes
- removed now-unused `newsletter` service that was providing mocked data for earlier design iteration
This commit is contained in:
Kevin Ansfield 2022-04-04 19:26:49 +01:00
parent 4be3c3b53f
commit 6e0be9e175
25 changed files with 764 additions and 177 deletions

View File

@ -0,0 +1,43 @@
<div class="modal-content">
<div class="modal-body modal-fullsettings">
<div class="flex items-center justify-between w-100 modal-fullsettings-topbar">
<h2 class="modal-fullsettings-heading">
{{if @data.newsletter.isNew "Add" "Edit"}} newsletter
</h2>
<div class="flex items-center">
<button class="gh-btn mr3" type="button" {{on "click" @close}}>
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Save and close"
@successText="Saved"
@task={{this.saveTask}}
@idleClass="gh-btn-primary"
@class="gh-btn gh-btn-icon"
{{on-key "cmd+s" this.saveViaKeyboard priority=1}}
data-test-button="save-newsletter"
/>
</div>
</div>
<div class="modal-fullsettings-body">
<div class="modal-fullsettings-sidebar with-footer">
<div class="gh-btn-group">
<button type="button" class="gh-btn gh-btn-icon {{if (eq this.tab "settings") "gh-btn-group-selected"}}" {{on "click" (fn this.changeTab "settings")}}><span>Settings</span></button>
<button type="button" class="gh-btn gh-btn-icon {{if (eq this.tab "design") "gh-btn-group-selected"}}" {{on "click" (fn this.changeTab "design")}}><span>Design</span></button>
</div>
{{#if (eq this.tab "settings")}}
<Modals::EditNewsletter::Settings @newsletter={{@data.newsletter}} />
{{else}}
<Modals::EditNewsletter::Design @newsletter={{@data.newsletter}} />
{{/if}}
</div>
<div class="modal-fullsettings-main">
<Modals::EditNewsletter::Preview @newsletter={{@data.newsletter}} />
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,48 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class EditNewsletterModal extends Component {
static modalOptions = {
className: 'fullscreen-modal-full-overlay fullscreen-modal-portal-settings'
};
@tracked tab = 'settings';
willDestroy() {
super.willDestroy(...arguments);
this.args.data.newsletter.rollbackAttributes();
}
@action
changeTab(tab) {
this.tab = tab;
}
@action
saveViaKeyboard(event, responder) {
responder.stopPropagation();
event.preventDefault();
this.saveTask.perform();
}
@task
*saveTask() {
try {
const result = yield this.args.data.newsletter.save();
this.args.data.afterSave?.(result);
return result;
} catch (e) {
if (e === undefined) {
// validation error
return false;
}
throw e;
}
}
}

View File

@ -0,0 +1,166 @@
<div>
<fieldset class="modal-fullsettings-form">
<div class="modal-fullsettings-section first">
<GhFormGroup @classNames="vertical">
<GhUploader
@extensions={{this.imageExtensions}}
@paramsHash={{hash purpose="image"}}
@onComplete={{fn this.imageUploaded "headerImage"}}
as |uploader|
>
<div class="modal-fullsettings-uploader">
<div class="gh-header-img-desc">
<h4 class="modal-fullsettings-title">Header image</h4>
<p>Optional, recommended size 1200x600</p>
</div>
{{#if uploader.isUploading}}
<div class="gh-header-img-container">
<div class="gh-loading-spinner"></div>
</div>
{{else if this.headerImage}}
<div class="gh-header-img">
<img
class="gh-header-img-thumbnail"
src={{@newsletter.headerImage}}
alt=""
role="presentation"
data-test-img="header"
>
<button type="button" class="gh-btn gh-header-img-deleteicon" {{on "click" (fn this.changeSetting "headerImage" null)}}>
<span> {{svg-jar "trash" class="w5 h5"}} </span>
</button>
</div>
{{else}}
<button type="button" class="gh-btn gh-header-img-uploadicon" {{on "click" uploader.triggerFileDialog}} data-test-image-upload-btn="header-image">
<span>{{svg-jar "upload-fill" class="w5 h5"}}</span>
</button>
<div style="display:none">
<GhFileInput
@multiple={{false}}
@action={{uploader.setFiles}}
@accept={{uploader.imageMimeTypes}}
@onInsert={{uploader.registerFileInput}}
data-test-file-input="icon" />
</div>
{{/if}}
</div>
</GhUploader>
</GhFormGroup>
<GhFormGroup data-tooltip={{unless this.settings.icon "A publication icon must be set in Branding settings."}}>
<h4 class="modal-fullsettings-title {{unless this.settings.icon "disabled"}}">Publication icon</h4>
<div class="for-switch small {{unless this.settings.icon "disabled"}}">
<label class="switch" for="show-header">
<input
type="checkbox"
checked={{and @newsletter.showHeaderIcon this.settings.icon}}
id="show-header"
name="show-header"
disabled={{not this.settings.icon}}
{{on "click" (fn this.toggleSetting "showHeaderIcon")}}
>
<span class="input-toggle-component"></span>
</label>
</div>
</GhFormGroup>
<GhFormGroup>
<h4 class="modal-fullsettings-title">Publication title</h4>
<div class="for-switch small">
<label class="switch" for="show-title">
<input
type="checkbox"
checked={{@newsletter.showHeaderTitle}}
id="show-title"
name="show-title"
{{on "click" (fn this.toggleSetting "showHeaderTitle")}}
>
<span class="input-toggle-component"></span>
</label>
</div>
</GhFormGroup>
</div>
<div class="modal-fullsettings-section divider-top">
<GhFormGroup>
<h4 class="modal-fullsettings-title gh-email-design-alignment">Header style</h4>
<div class="gh-email-design-typography-wrapper header">
<div class="modal-fullsettings-radiogroup gh-email-design-typography">
<GhFontSelector
@selected={{@newsletter.titleFontCategory}}
@onChange={{fn this.changeSetting "titleFontCategory"}}
/>
</div>
<div class="gh-btn-group icons">
<button type="button" class="gh-btn gh-btn-icon {{if (eq @newsletter.titleAlignment "left") "gh-btn-group-selected"}}" {{on "click" (fn this.changeSetting "titleAlignment" "left")}}><span>{{svg-jar "align-left"}}</span></button>
<button type="button" class="gh-btn gh-btn-icon {{if (eq @newsletter.titleAlignment "center") "gh-btn-group-selected"}}" {{on "click" (fn this.changeSetting "titleAlignment" "center")}}><span>{{svg-jar "align-center"}}</span></button>
</div>
</div>
</GhFormGroup>
<GhFormGroup>
<h4 class="modal-fullsettings-title">Feature image</h4>
<div class="for-switch small">
<label class="switch" for="show-feature-image">
<input
type="checkbox"
checked={{@newsletter.showFeatureImage}}
id="show-feature-image"
name="show-feature-image"
{{on "click" (fn this.toggleSetting "showFeatureImage")}}
>
<span class="input-toggle-component"></span>
</label>
</div>
</GhFormGroup>
</div>
<div class="modal-fullsettings-section">
<GhFormGroup>
<h4 class="modal-fullsettings-title">Body style</h4>
<div class="gh-email-design-typography-wrapper">
<div class="modal-fullsettings-radiogroup gh-email-design-typography">
<GhFontSelector
@selected={{@newsletter.bodyFontCategory}}
@onChange={{fn this.changeSetting "bodyFontCategory"}}
/>
</div>
</div>
</GhFormGroup>
</div>
<div class="modal-fullsettings-section divider-top">
<GhFormGroup @classNames="vertical">
<h4 class="modal-fullsettings-title">Email footer</h4>
<KoenigBasicHtmlInput
@name="footer"
@html={{@newsletter.footerContent}}
@class="miw-100 form-text gh-members-emailsettings-footer-input"
@onChange={{fn this.changeSetting "footerContent"}}
/>
<p>Any extra information or legal text</p>
</GhFormGroup>
</div>
</fieldset>
</div>
<div class="modal-fullsettings-section gh-members-emailsettings-footer">
<GhFormGroup>
<div class="gh-members-emailsettings-promotelabel">
<span>{{svg-jar "heart"}}</span>
<div>
<h4 class="modal-fullsettings-title">Promote independent publishing</h4>
<p>Show youre a part of the indie publishing movement with a small badge in the footer</p>
</div>
</div>
<div class="for-switch small">
<label
class="switch"
for="promote-ghost"
>
<input
type="checkbox"
checked={{@newsletter.showBadge}}
id="promote-ghost"
name="promote-ghost"
{{on "click" (fn this.toggleSetting "showBadge")}}
>
<span class="input-toggle-component"></span>
</label>
</div>
</GhFormGroup>
</div>

View File

@ -0,0 +1,27 @@
import Component from '@glimmer/component';
import {IMAGE_EXTENSIONS} from 'ghost-admin/components/gh-image-uploader';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class EditNewsletterDesignForm extends Component {
@service settings;
imageExtensions = IMAGE_EXTENSIONS;
@action
imageUploaded(property, images) {
if (images[0]) {
this.args.newsletter[property] = images[0].url;
}
}
@action
changeSetting(property, value) {
this.args.newsletter[property] = value;
}
@action
toggleSetting(property, event) {
this.args.newsletter[property] = event.target.checked;
}
}

View File

@ -0,0 +1,55 @@
<div class="modal-fullsettings-preview-container gh-members-emailpreview">
<div class="gh-members-emailpreview-container">
<div class="gh-members-emailpreview-faux">
<p>
<span class="strong">{{or @newsletter.senderName this.config.blogTitle}}</span> &lt;{{full-email-address (or @newsletter.senderEmail this.settings.membersFromAddress)}}&gt;
</p>
<p><span class="dark">To:</span> Jamie Larson &lt;jamie@example.com&gt;</p>
</div>
<div class="gh-members-emailpreview-contents">
{{#if @newsletter.headerImage}}
<div class="gh-members-emailpreview-header-image">
<img src={{@newsletter.headerImage}} alt="" role="presentation">
</div>
{{/if}}
{{#if this.showHeader}}
<div class={{if @newsletter.showHeaderTitle "gh-members-emailpreview-header bordered" "gh-members-emailpreview-header"}}>
{{#if (and this.settings.icon @newsletter.showHeaderIcon)}}
<img src={{this.settings.icon}} alt="" role="presentation" />
{{/if}}
{{#if @newsletter.showHeaderTitle}}
<h4>{{or @newsletter.name this.config.blogTitle}}</h4>
{{/if}}
</div>
{{/if}}
<div class="gh-members-emailpreview-title {{if (eq @newsletter.titleAlignment "left") "gh-members-emailpreview-title-left"}}">
<h2 class="{{if (eq @newsletter.titleFontCategory "serif") "serif"}}">Your email newsletter</h2>
<p>
<span>By {{or this.session.user.name this.session.user.email}} {{moment-format (moment-site-tz) "D MMM YYYY"}}  </span> <a href="javascript:">View online →</a>
</p>
</div>
{{#if @newsletter.showFeatureImage}}
<div class="gh-members-emailpreview-featureimage" style={{this.featureImageStyle}}></div>
<div class="gh-members-emailpreview-featureimage-caption">Feature image caption</div>
{{/if}}
<div class="gh-members-emailpreview-content {{if (eq @newsletter.bodyFontCategory "sans_serif") "sans-serif"}}">
<p>This is what your content will look like when you send one of your posts as an email newsletter to your subscribers.</p>
<p>Over there on the left youll see some settings that allow you to customize the look and feel of this template to make it perfectly suited to your brand. Email templates are exceptionally finnicky to make, but weve spent a long time optimising this one to make it work beautifully across devices, email clients and content types.</p>
<p>So, you can trust that every email you send with Ghost will look great and work well. Just like the rest of your site.</p>
</div>
<div class="gh-members-emailpreview-footer">
<div class="gh-members-emailpreview-footercontent">
{{html-safe @newsletter.footerContent}}
</div>
<div class="gh-members-emailpreview-footersite">
<span>{{this.config.blogTitle}} &copy; {{moment-format (moment-site-tz) "YYYY"}} </span> <a href="javascript:">Unsubscribe</a>
</div>
</div>
<div class="gh-members-emailpreview-badge {{unless @newsletter.showBadge "hide"}}">
<a href="javascript:"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 12C24 18.6274 18.6274 24 12 24C5.37258 24 0 18.6274 0 12C0 5.37258 5.37258 0 12 0C18.6274 0 24 5.37258 24 12ZM11.8326 2.33879C6.37785 2.95189 3.95901 5.20797 3.41126 9.74699C3.34896 10.2632 3.22642 10.7805 3.10443 11.2954C2.93277 12.02 2.76221 12.74 2.76221 13.4458C2.76221 17.9885 6.5856 21.556 11.1283 21.556C12.8959 21.556 14.4433 20.8144 15.8756 20.048C19.0536 18.3478 22.0328 16.2597 22.0328 12.5411C22.0328 9.91512 20.1051 7.56932 18.466 5.5747C18.3834 5.47416 18.3015 5.37451 18.2206 5.27577C17.3866 4.25742 14.4333 2.04643 11.8326 2.33879Z" fill="black"/>
</svg> <span>Powered by Ghost</span></a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,26 @@
import Component from '@glimmer/component';
import {htmlSafe} from '@ember/template';
import {inject as service} from '@ember/service';
export default class EditNewsletterPreview extends Component {
@service config;
@service ghostPaths;
@service session;
@service settings;
get showHeader() {
return (this.args.newsletter.showHeaderIcon && this.settings.get('icon'))
|| this.args.newsletter.showHeaderTitle;
}
get featureImageUrl() {
// keep path separate so asset rewriting correctly picks it up
const imagePath = '/img/user-cover.png';
const fullPath = this.ghostPaths.assetRoot.replace(/\/$/, '') + imagePath;
return fullPath;
}
get featureImageStyle() {
return htmlSafe(`background-image: url(${this.featureImageUrl})`);
}
}

View File

@ -0,0 +1,86 @@
<div>
<fieldset class="modal-fullsettings-form">
<div class="modal-fullsettings-section first">
<GhFormGroup @classNames="vertical" @errors={{@newsletter.errors}} @hasValidated={{@newsletter.hasValidated}} @property="name">
<label for="newsletter-title" class="modal-fullsettings-title">Name</label>
<input
id="newsletter-title"
type="text"
class="gh-input miw-100 form-text"
value={{@newsletter.name}}
placeholder={{this.config.blogTitle}}
{{on "input" (fn this.onInput "name")}}
/>
<GhErrorMessage @errors={{@newsletter.errors}} @property="name" />
</GhFormGroup>
<GhFormGroup @classNames="vertical" @errors={{@newsletter.errors}} @hasValidated={{@newsletter.hasValidated}} @property="description">
<label for="newsletter-description" class="modal-fullsettings-title">Description</label>
<textarea
id="newsletter-description"
class="gh-input miw-100 form-text"
{{on "input" (fn this.onInput "description")}}
>{{@newsletter.description}}</textarea>
<GhErrorMessage @errors={{@newsletter.errors}} @property="description" />
</GhFormGroup>
</div>
<div class="modal-fullsettings-section divider-top">
<GhFormGroup @classNames="vertical" @errors={{@newsletter.errors}} @hasValidated={{@newsletter.hasValidated}} @property="senderName">
<label for="newsletter-sender-name" class="modal-fullsettings-title">Sender name</label>
<input
id="newsletter-sender-name"
type="text"
class="gh-input miw-100 form-text"
value={{@newsletter.senderName}}
placeholder={{this.config.blogTitle}}
{{on "input" (fn this.onInput "senderName")}}
/>
<GhErrorMessage @errors={{@newsletter.errors}} @property="senderName" />
</GhFormGroup>
<GhFormGroup @classNames="vertical" @errors={{@newsletter.errors}} @hasValidated={{@newsletter.hasValidated}} @property="senderEmail">
<label for="newsletter-sender-email" class="modal-fullsettings-title">Newsletter email address</label>
<input
id="newsletter-sender-email"
type="text"
class="gh-input miw-100 form-text"
value={{@newsletter.senderEmail}}
placeholder={{full-email-address this.settings.membersFromAddress}}
{{on "input" (fn this.onInput "senderEmail")}}
/>
<GhErrorMessage @errors={{@newsletter.errors}} @property="senderEmail" />
</GhFormGroup>
<GhFormGroup @classNames="vertical" @errors={{@newsletter.errors}} @hasValidated={{@newsletter.hasValidated}} @property="senderReplyTo">
<label for="newsletter-reply-to" class="modal-fullsettings-title">Reply-to address</label>
<input
id="newsletter-reply-to"
type="text"
class="gh-input miw-100 form-text"
value={{@newsletter.senderReplyTo}}
placeholder={{full-email-address this.settings.membersFromAddress}}
{{on "input" (fn this.onInput "senderReplyTo")}}
/>
<GhErrorMessage @errors={{@newsletter.errors}} @property="senderReplyTo" />
</GhFormGroup>
</div>
<div class="modal-fullsettings-section divider-top">
<GhFormGroup>
<label for="subscribe-on-signup" class="modal-fullsettings-title">Subscribe new members on signup</label>
<div class="for-switch small">
<div class="container">
<input
type="checkbox"
id="subscribe-on-signup"
checked={{@newsletter.subscribeOnSignup}}
{{on "change" (fn this.onChange "subscribeOnSignup")}}
>
<button type="button" class="input-toggle-component" {{on "click" (fn this.toggleProperty "subscribeOnSignup")}}></button>
</div>
</div>
</GhFormGroup>
</div>
</fieldset>
</div>

View File

@ -0,0 +1,23 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class EditNewsletterSettingsForm extends Component {
@service config;
@service settings;
@action
onChange(property, event) {
this.args.newsletter[property] = event.target.checked;
}
@action
toggleProperty(property) {
this.args.newsletter[property] = !this.args.newsletter[property];
}
@action
onInput(property, event) {
this.args.newsletter[property] = event.target.value;
}
}

View File

@ -25,7 +25,7 @@
</section> </section>
{{#if this.emailNewsletterEnabled}} {{#if this.emailNewsletterEnabled}}
<Settings::MembersEmailLabs::NewsletterManagement @toggleEmailDesignSettings={{@toggleEmailDesignSettings}} /> <Settings::MembersEmailLabs::NewsletterManagement />
<section class="gh-main-section"> <section class="gh-main-section">
<h4 class="gh-main-section-header small bn">General settings</h4> <h4 class="gh-main-section-header small bn">General settings</h4>

View File

@ -20,7 +20,6 @@ export default class MembersEmailLabs extends Component {
@tracked recipientsSelectValue = this._getDerivedRecipientsSelectValue(); @tracked recipientsSelectValue = this._getDerivedRecipientsSelectValue();
@tracked showFromAddressConfirmation = false; @tracked showFromAddressConfirmation = false;
@tracked showEmailDesignSettings = false;
mailgunRegions = [US, EU]; mailgunRegions = [US, EU];
@ -78,11 +77,6 @@ export default class MembersEmailLabs extends Component {
this.showFromAddressConfirmation = !this.showFromAddressConfirmation; this.showFromAddressConfirmation = !this.showFromAddressConfirmation;
} }
@action
closeEmailDesignSettings() {
this.showEmailDesignSettings = false;
}
@action @action
setMailgunDomain(event) { setMailgunDomain(event) {
this.settings.set('mailgunDomain', event.target.value); this.settings.set('mailgunDomain', event.target.value);

View File

@ -25,8 +25,10 @@
</div> </div>
<section class="gh-expandable"> <section class="gh-expandable">
<div class="gh-expandable-block"> <div class="gh-expandable-block">
{{#each this.newsletters.newsletters as |newsletter|}} {{#if this.loadNewslettersTask.isRunning}}
{{#if (eq newsletter.status "active")}} <div class="gh-main-content">... loading</div>
{{else}}
{{#each this.activeNewsletters as |newsletter|}}
<div class="gh-main-content-card gh-newsletter-card {{if this.hasMultiple "multiple"}}"> <div class="gh-main-content-card gh-newsletter-card {{if this.hasMultiple "multiple"}}">
{{svg-jar "grab" class="grab-newsletter"}} {{svg-jar "grab" class="grab-newsletter"}}
<div class="gh-newsletter-card-block title-block"> <div class="gh-newsletter-card-block title-block">
@ -65,25 +67,25 @@
@classNames="gh-newsletter-actions-menu dropdown-menu dropdown-triangle-top-right" @classNames="gh-newsletter-actions-menu dropdown-menu dropdown-triangle-top-right"
> >
<li> <li>
<button class="mr2" type="button" {{on "click" @toggleEmailDesignSettings}}> <LinkTo @route="settings.members-email-labs.edit-newsletter" @model={{newsletter.id}} class="mr2"><span>Edit</span></LinkTo>
<span>Edit</span>
</button>
</li> </li>
<li> <li>
<button class="mr2" type="button" {{on "click" (fn this.archiveNewsletter newsletter.id)}}> <button class="mr2" type="button" {{on "click" (perform this.archiveNewsletterTask newsletter)}}>
<span>Archive</span> <span>Archive</span>
</button> </button>
</li> </li>
</GhDropdown> </GhDropdown>
</span> </span>
{{else}} {{else}}
<button class="gh-btn gh-btn-green" type="button" {{on "click" @toggleEmailDesignSettings}}><span>Customize &rarr;</span></button> <LinkTo @route="settings.members-email-labs.edit-newsletter" @model={{newsletter.id}} class="gh-btn gh-btn-green"><span>Customize &rarr;</span></LinkTo>
{{/if}} {{/if}}
</div> </div>
</div> </div>
{{/if}} {{else}}
<div class="gh-main-content">No newsletters found</div>
{{/each}} {{/each}}
{{/if}}
</div> </div>
</section> </section>
<button type="button" class="gh-add-newsletter" {{on "click" this.addNewsletter}} {{on "click" @toggleEmailDesignSettings}}>{{svg-jar "add-stroke"}}Add newsletter</button> <LinkTo @route="settings.members-email-labs.new-newsletter" class="gh-add-newsletter">{{svg-jar "add-stroke"}}Add newsletter</LinkTo>
</div> </div>

View File

@ -1,30 +1,37 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class NewsletterManagementComponent extends Component { export default class NewsletterManagementComponent extends Component {
@service newsletters;
@service store; @service store;
newsletters = this.store.peekAll('newsletter');
constructor() {
super(...arguments);
this.loadNewslettersTask.perform();
}
get activeNewsletters() { get activeNewsletters() {
return this.newsletters.newsletters.filter(n => n.status === 'active'); return this.newsletters.filter(n => n.status === 'active');
} }
get archivedNewsletters() { get archivedNewsletters() {
return this.newsletters.newsletters.filter(n => n.status === 'archived'); return this.newsletters.filter(n => n.status === 'archived');
} }
get hasMultiple() { get hasMultiple() {
return this.activeNewsletters.length > 1; return this.activeNewsletters.length > 1;
} }
@action @task
addNewsletter() { *archiveNewsletterTask(newsletter) {
this.newsletters.add(); newsletter.status = 'archived';
return yield newsletter.save();
} }
@action @task
archiveNewsletter(id) { *loadNewslettersTask() {
this.newsletters.archive(id); return yield this.store.findAll('newsletter');
} }
} }

View File

@ -9,48 +9,16 @@ export default class MembersEmailLabsController extends Controller {
@service session; @service session;
@service settings; @service settings;
queryParams = ['showEmailDesignSettings'];
// from/supportAddress are set here so that they can be reset to saved values on save // from/supportAddress are set here so that they can be reset to saved values on save
// to avoid it looking like they've been saved when they have a separate update process // to avoid it looking like they've been saved when they have a separate update process
@tracked fromAddress = ''; @tracked fromAddress = '';
@tracked supportAddress = ''; @tracked supportAddress = '';
@tracked showEmailDesignSettings = false;
@tracked showLeaveSettingsModal = false;
@action @action
setEmailAddress(property, email) { setEmailAddress(property, email) {
this[property] = email; this[property] = email;
} }
@action
toggleEmailDesignSettings() {
this.showEmailDesignSettings = !this.showEmailDesignSettings;
}
leaveRoute(transition) {
if (this.settings.get('hasDirtyAttributes')) {
transition.abort();
this.leaveSettingsTransition = transition;
this.showLeaveSettingsModal = true;
}
this.showEmailDesignSettings = false;
}
@action
async confirmLeave() {
this.settings.rollbackAttributes();
this.showLeaveSettingsModal = false;
this.leaveSettingsTransition.retry();
}
@action
cancelLeave() {
this.showLeaveSettingsModal = false;
this.leaveSettingsTransition = null;
}
parseEmailAddress(address) { parseEmailAddress(address) {
const emailAddress = address || 'noreply'; const emailAddress = address || 'noreply';
// Adds default domain as site domain // Adds default domain as site domain

View File

@ -4,7 +4,7 @@ import {inject as service} from '@ember/service';
export default class FullEmailAddressHelper extends Helper { export default class FullEmailAddressHelper extends Helper {
@service config; @service config;
compute([email]) { compute([email = '']) {
if (email.indexOf('@') > -1) { if (email.indexOf('@') > -1) {
return email; return email;
} }

View File

@ -9,6 +9,7 @@ import MemberValidator from 'ghost-admin/validators/member';
import Mixin from '@ember/object/mixin'; import Mixin from '@ember/object/mixin';
import Model from '@ember-data/model'; import Model from '@ember-data/model';
import NavItemValidator from 'ghost-admin/validators/nav-item'; import NavItemValidator from 'ghost-admin/validators/nav-item';
import NewsletterValidator from 'ghost-admin/validators/newsletter';
import OfferValidator from 'ghost-admin/validators/offer'; import OfferValidator from 'ghost-admin/validators/offer';
import PostValidator from 'ghost-admin/validators/post'; import PostValidator from 'ghost-admin/validators/post';
import ProductBenefitItemValidator from 'ghost-admin/validators/product-benefit-item'; import ProductBenefitItemValidator from 'ghost-admin/validators/product-benefit-item';
@ -75,7 +76,8 @@ export default Mixin.create({
label: LabelValidator, label: LabelValidator,
snippet: SnippetValidator, snippet: SnippetValidator,
product: ProductValidator, product: ProductValidator,
offer: OfferValidator offer: OfferValidator,
newsletter: NewsletterValidator
}; };
}, },

View File

@ -0,0 +1,32 @@
import Model, {attr} from '@ember-data/model';
import ValidationEngine from '../mixins/validation-engine';
export default class Newsletter extends Model.extend(ValidationEngine) {
validationType = 'newsletter';
@attr name;
@attr description;
@attr senderName;
@attr senderEmail;
@attr senderReplyTo;
@attr({defaultValue: 'active'}) status;
@attr({defaultValue: ''}) recipientFilter;
@attr({defaultValue: false}) subscribeOnSignup;
@attr({defaultValue: 0}) sortOrder;
// Design-related properties - TODO: not currently supported in API
@attr headerImage;
@attr({defaultValue: true}) showHeaderIcon;
@attr({defaultValue: true}) showHeaderTitle;
@attr({defaultValue: 'sans_serif'}) titleFontCategory;
@attr({defaultValue: 'center'}) titleAlignment;
@attr({defaultValue: true}) showFeatureImage;
@attr({defaultValue: 'sans_serif'}) bodyFontCategory;
@attr() footerContent;
@attr({defaultValue: true}) showBadge;
// TODO: delete attr, incorrectly needed for save to complete in API
@attr({defaultValue: false}) default;
}

View File

@ -43,9 +43,13 @@ Router.map(function () {
this.route('settings.general', {path: '/settings/general'}); this.route('settings.general', {path: '/settings/general'});
this.route('settings.membership', {path: '/settings/members'}); this.route('settings.membership', {path: '/settings/members'});
this.route('settings.members-email', {path: '/settings/members-email'}); this.route('settings.members-email', {path: '/settings/members-email'});
this.route('settings.members-email-labs', {path: '/settings/members-email-labs'});
this.route('settings.code-injection', {path: '/settings/code-injection'}); this.route('settings.code-injection', {path: '/settings/code-injection'});
this.route('settings.members-email-labs', {path: '/settings/members-email-labs'}, function () {
this.route('new-newsletter', {path: '/newsletters/new'});
this.route('edit-newsletter', {path: '/newsletters/:newsletter_id'});
});
this.route('settings.design', {path: '/settings/design'}, function () { this.route('settings.design', {path: '/settings/design'}, function () {
this.route('change-theme', function () { this.route('change-theme', function () {
this.route('view', {path: ':theme_name'}); this.route('view', {path: ':theme_name'});

View File

@ -1,12 +1,17 @@
import AdminRoute from 'ghost-admin/routes/admin'; import AdminRoute from 'ghost-admin/routes/admin';
import ConfirmUnsavedChangesModal from '../../components/modals/confirm-unsaved-changes';
import {action} from '@ember/object'; import {action} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
export default class MembersEmailLabsRoute extends AdminRoute { export default class MembersEmailLabsRoute extends AdminRoute {
@service feature; @service feature;
@service modals;
@service notifications; @service notifications;
@service settings; @service settings;
confirmModal = null;
hasConfirmed = false;
beforeModel(transition) { beforeModel(transition) {
super.beforeModel(...arguments); super.beforeModel(...arguments);
@ -32,7 +37,44 @@ export default class MembersEmailLabsRoute extends AdminRoute {
@action @action
willTransition(transition) { willTransition(transition) {
return this.controller.leaveRoute(transition); if (this.hasConfirmed) {
return true;
}
// always abort when not confirmed because Ember's router doesn't automatically wait on promises
transition.abort();
this.confirmUnsavedChanges().then((shouldLeave) => {
if (shouldLeave) {
this.hasConfirmed = true;
return transition.retry();
}
});
}
deactivate() {
this.confirmModal = null;
this.hasConfirmed = false;
}
confirmUnsavedChanges() {
if (!this.settings.get('hasDirtyAttributes')) {
return Promise.resolve(true);
}
if (!this.confirmModal) {
this.confirmModal = this.modals.open(ConfirmUnsavedChangesModal)
.then((discardChanges) => {
if (discardChanges === true) {
this.settings.rollbackAttributes();
}
return discardChanges;
}).finally(() => {
this.confirmModal = null;
});
}
return this.confirmModal;
} }
buildRouteInfoMetadata() { buildRouteInfoMetadata() {

View File

@ -0,0 +1,47 @@
import AdminRoute from 'ghost-admin/routes/admin';
import EditNewsletterModal from 'ghost-admin/components/modals/edit-newsletter';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class EditNewsletterRoute extends AdminRoute {
@service modals;
@service router;
@service store;
newsletterModal = null;
model(params) {
return this.store.find('newsletter', params.newsletter_id);
}
setupController(controller, model) {
this.newsletterModal?.close();
this.newsletterModal = this.modals.open(EditNewsletterModal, {
newsletter: model,
afterSave: this.afterSave
}, {
beforeClose: this.beforeModalClose
});
}
deactivate() {
this.isLeaving = true;
this.newsletterModal?.close();
this.isLeaving = false;
this.newsletterModal = null;
}
@action
afterSave() {
this.router.transitionTo('settings.members-email-labs');
}
@action
beforeModalClose() {
if (this.newsletterModal && !this.isLeaving) {
this.router.transitionTo('settings.members-email-labs');
}
}
}

View File

@ -0,0 +1,47 @@
import AdminRoute from 'ghost-admin/routes/admin';
import EditNewsletterModal from 'ghost-admin/components/modals/edit-newsletter';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class NewNewsletterRoute extends AdminRoute {
@service modals;
@service router;
@service store;
newsletterModal = null;
model() {
return this.store.createRecord('newsletter');
}
setupController(controller, model) {
this.newsletterModal?.close();
this.newsletterModal = this.modals.open(EditNewsletterModal, {
newsletter: model,
afterSave: this.afterSave
}, {
beforeClose: this.beforeModalClose
});
}
deactivate() {
this.isLeaving = true;
this.newsletterModal?.close();
this.isLeaving = false;
this.newsletterModal = null;
}
@action
afterSave() {
this.router.transitionTo('settings.members-email-labs');
}
@action
beforeModalClose() {
if (this.newsletterModal && !this.isLeaving) {
this.router.transitionTo('settings.members-email-labs');
}
}
}

View File

@ -1,94 +0,0 @@
import Service, {inject as service} from '@ember/service';
import {TrackedArray} from 'tracked-built-ins';
import {set} from '@ember/object';
import {tracked} from '@glimmer/tracking';
class Newsletter {
@tracked name;
@tracked description;
@tracked sender_name;
@tracked sender_email;
@tracked sender_reply_to;
@tracked default;
@tracked status;
@tracked recipient_filter;
@tracked subscribe_on_signup;
@tracked sort_order;
constructor(obj) {
Object.assign(this, obj);
}
}
let counter = 0;
function getFakeNewsletter() {
counter += 1;
return new Newsletter({
id: Math.floor(Math.random() * 1e9),
name: 'Daily roundup ' + counter,
description: 'Daily news delivered to your inbox every morning.',
sender_name: 'Test',
sender_email: 'test@example.com',
sender_reply_to: 'test@example.com',
default: false,
status: 'active',
recipient_filter: '',
subscribe_on_signup: true,
sort_order: counter,
members: {
total: Math.floor(Math.random() * 100)
},
posts: {
total: Math.floor(Math.random() * 100)
}
});
}
export default class NewslettersService extends Service {
@service config;
@service settings;
@service feature;
@service store;
newsletters = new TrackedArray([
new Newsletter({
id: '123',
name: 'Daily roundup',
description: 'Daily news delivered to your inbox every morning.',
sender_name: 'Test',
sender_email: 'test@example.com',
sender_reply_to: 'test@example.com',
default: true,
status: 'active',
recipient_filter: '',
subscribe_on_signup: true,
sort_order: 0,
members: {
total: 19
},
posts: {
total: 17
}
})
]);
add() {
this.newsletters.push(getFakeNewsletter());
this.newsletters.sort((a,b) => a.sort_order - b.sort_order);
return this.newsletters;
}
archive(newsletterId) {
const newsletter = this.newsletters.find(n => n.id === newsletterId);
if (newsletter) {
set(newsletter, 'status', 'archived');
}
}
unArchive(newsletterId) {
const newsletter = this.newsletters.find(n => n.id === newsletterId);
if (newsletter) {
set(newsletter, 'status', 'active');
}
}
}

View File

@ -24,25 +24,7 @@
@fromAddress={{this.fromAddress}} @fromAddress={{this.fromAddress}}
@supportAddress={{this.supportAddress}} @supportAddress={{this.supportAddress}}
@setEmailAddress={{this.setEmailAddress}} @setEmailAddress={{this.setEmailAddress}}
@toggleEmailDesignSettings={{this.toggleEmailDesignSettings}}
/> />
</div> </div>
</section> </section>
{{#if this.showLeaveSettingsModal}}
<GhFullscreenModal
@modal="leave-settings"
@confirm={{this.confirmLeave}}
@close={{this.cancelLeave}}
@modifier="action wide"
/>
{{/if}}
</section> </section>
{{#if this.showEmailDesignSettings}}
<GhFullscreenModal @modifier="full-overlay portal-settings">
<ModalEmailDesignSettings
@closeModal={{this.toggleEmailDesignSettings}}
/>
</GhFullscreenModal>
{{/if}}

View File

@ -0,0 +1,69 @@
import BaseValidator from './base';
import validator from 'validator';
import {isBlank} from '@ember/utils';
export default BaseValidator.create({
properties: ['name', 'senderName', 'senderEmail', 'senderReplyTo'],
name(model) {
if (isBlank(model.name)) {
model.errors.add('name', 'Please enter a name.');
this.invalidate();
}
if (!validator.isLength(model.name || '', 0, 191)) {
model.errors.add('name', 'Name cannot be longer than 191 characters.');
this.invalidate();
}
model.hasValidated.addObject('name');
},
senderName(model) {
if (isBlank(model.senderName)) {
model.errors.add('senderName', 'Please enter a sender name.');
this.invalidate();
}
if (!validator.isLength(model.senderName || '', 0, 191)) {
model.errors.add('senderName', 'Sender name cannot be longer than 191 characters.');
this.invalidate();
}
model.hasValidated.addObject('senderName');
},
senderEmail(model) {
if (isBlank(model.senderEmail)) {
model.errors.add('senderEmail', 'Please enter a newsletter email address.');
this.invalidate();
} else if (!validator.isEmail(model.senderEmail)) {
model.errors.add('senderEmail', 'Invalid email.');
this.invalidate();
}
if (!validator.isLength(model.senderEmail || '', 0, 191)) {
model.errors.add('senderEmail', 'Sender email cannot be longer than 191 characters.');
this.invalidate();
}
model.hasValidated.addObject('senderEmail');
},
senderReplyTo(model) {
if (isBlank(model.senderReplyTo)) {
model.errors.add('senderReplyTo', 'Please enter a reply-to email address.');
this.invalidate();
} else if (!validator.isEmail(model.senderReplyTo)) {
model.errors.add('senderReplyTo', 'Invalid email.');
this.invalidate();
}
if (!validator.isLength(model.senderReplyTo || '', 0, 191)) {
model.errors.add('senderReplyTo', 'Reply-to email cannot be longer than 191 characters.');
this.invalidate();
}
model.hasValidated.addObject('senderReplyTo');
}
});

View File

@ -8,6 +8,7 @@ import mockIntegrations from './config/integrations';
import mockInvites from './config/invites'; import mockInvites from './config/invites';
import mockLabels from './config/labels'; import mockLabels from './config/labels';
import mockMembers from './config/members'; import mockMembers from './config/members';
import mockNewsletters from './config/newsletters';
import mockOffers from './config/offers'; import mockOffers from './config/offers';
import mockPages from './config/pages'; import mockPages from './config/pages';
import mockPosts from './config/posts'; import mockPosts from './config/posts';
@ -38,7 +39,7 @@ export default function () {
// this.put('/posts/:id/', versionMismatchResponse); // this.put('/posts/:id/', versionMismatchResponse);
// mockTags(this); // mockTags(this);
// this.loadFixtures('settings'); // this.loadFixtures('settings');
mockSnippets(this); mockNewsletters(this);
// keep this line, it allows all other API requests to hit the real server // keep this line, it allows all other API requests to hit the real server
this.passthrough(); this.passthrough();
@ -78,6 +79,8 @@ export function testConfig() {
mockWebhooks(this); mockWebhooks(this);
mockProducts(this); mockProducts(this);
mockOffers(this); mockOffers(this);
mockSnippets(this);
mockNewsletters(this);
/* Notifications -------------------------------------------------------- */ /* Notifications -------------------------------------------------------- */

View File

@ -0,0 +1,8 @@
import {paginatedResponse} from '../utils';
export default function mockNewsletters(server) {
server.post('/newsletters/');
server.get('/newsletters/', paginatedResponse('newsletters'));
server.get('/newsletters/:id/');
server.put('/newsletters/:id/');
}