Refactored custom integration creation and limits modals

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

- switched to new ember-promise-modals pattern
- removed controller and template in favor of opening modals directly from the route
- removed unused `mousedown` event handlers - they are only necessary when an input blur would trigger validation errors
- fixed Enter key not triggering create action by adding an `{{on-key "Enter"}}` event handler to the name input
- fixed scroll not resetting to top of integrations screens when navigating between them by adding `{{scroll-top}}` element modifier to the main content sections
This commit is contained in:
Kevin Ansfield 2022-01-13 13:16:13 +00:00
parent 6a43cb27c3
commit d0f6dd7fef
12 changed files with 173 additions and 208 deletions

View File

@ -1,47 +0,0 @@
<header class="modal-header" data-test-modal="new-integration">
<h1>New custom integration</h1>
</header>
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
<button class="close" href title="Close" {{action "closeModal"}} {{action (optional this.noop) on="mouseDown"}}>
{{svg-jar "close"}}
</button>
<div class="modal-body">
<fieldset>
<GhFormGroup @errors={{this.integration.errors}} @hasValidated={{this.integration.hasValidated}} @property="name">
<label for="new-integration-name" class="fw6">Name</label>
<input type="text"
value={{this.integration.name}}
oninput={{action "updateName" value="target.value"}}
id="new-integration-name"
class="gh-input mt1"
name="integration-name"
autofocus="autofocus"
autocapitalize="off"
autocorrect="off"
data-test-input="new-integration-name">
<GhErrorMessage @errors={{this.integration.errors}} @property="name" data-test-error="new-integration-name" />
</GhFormGroup>
</fieldset>
{{#if this.errorMessage}}
<p class="error"><strong class="response">{{this.errorMessage}}</strong></p>
{{/if}}
</div>
<div class="modal-footer">
<button
class="gh-btn"
{{action "closeModal"}}
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
{{action (optional this.noop) on="mouseDown"}}
data-test-button="cancel-new-integration"
>
<span>Cancel</span>
</button>
<GhTaskButton @buttonText="Create"
@successText="Created"
@task={{this.createIntegration}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="create-integration" />
</div>

View File

@ -1,59 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {A as emberA} from '@ember/array';
import {isHostLimitError} from 'ghost-admin/services/ajax';
import {isInvalidError} from 'ember-ajax/errors';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
router: service(),
feature: service(),
errorMessage: null,
confirm() {},
integration: alias('model'),
actions: {
updateName(name) {
this.integration.set('name', name);
this.integration.set('hasValidated', emberA());
this.integration.errors.clear();
},
confirm() {
return this.createIntegration.perform();
}
},
createIntegration: task(function* () {
try {
let integration = yield this.confirm();
this.router.transitionTo('settings.integration', integration);
} catch (error) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to model.errors automatically
if (error && isInvalidError(error)) {
let [firstError] = error.payload.errors;
let {message} = firstError;
if (message && message.match(/name/i)) {
this.get('integration.errors').add('name', message);
this.get('integration.hasValidated').pushObject('name');
return;
}
}
if (isHostLimitError(error)) {
this.set('errorMessage', error.payload.errors[0].context);
return;
}
// bubble up to the global error handler
if (error) {
throw error;
}
}
}).drop()
});

View File

@ -1,20 +0,0 @@
<header class="modal-header" data-test-modal="delete-user">
<h1>Upgrade to enable custom integrations</h1>
</header>
<button class="close" title="Close" {{on "click" this.closeModal}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
{{html-safe this.model.message}}
</p>
</div>
<div class="modal-footer">
<button {{on "click" this.closeModal}} class="gh-btn" data-test-button="cancel-upgrade">
<span>Cancel</span>
</button>
<button {{on "click" (action "upgrade")}} class="gh-btn gh-btn-green" data-test-button="upgrade-plan">
<span>Upgrade my plan</span>
</button>
</div>

View File

@ -1,16 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {inject as service} from '@ember/service';
export default ModalComponent.extend({
router: service(),
actions: {
upgrade() {
this.router.transitionTo('pro');
},
confirm() {
this.send('upgrade');
}
}
});

View File

@ -0,0 +1,22 @@
<div class="modal-content" data-test-modal="limits/custom-integration">
<header class="modal-header">
<h1>Upgrade to enable custom integrations</h1>
</header>
<button class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
{{html-safe @data.message}}
</p>
</div>
<div class="modal-footer">
<button {{on "click" @close}} class="gh-btn" data-test-button="cancel-upgrade">
<span>Cancel</span>
</button>
<LinkTo @route="pro" class="gh-btn gh-btn-green" {{on "click" @close}} data-test-button="upgrade-plan">
<span>Upgrade my plan</span>
</LinkTo>
</div>
</div>

View File

@ -0,0 +1,48 @@
<div class="modal-content">
<header class="modal-header" data-test-modal="new-integration">
<h1>New custom integration</h1>
</header>
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
<button class="close" href title="Close" {{on "click" @close}}>
{{svg-jar "close"}}
</button>
<div class="modal-body">
<fieldset>
<GhFormGroup @errors={{this.integration.errors}} @hasValidated={{this.integration.hasValidated}} @property="name">
<label for="new-integration-name" class="fw6">Name</label>
<input type="text"
value={{this.integration.name}}
{{on "input" this.updateName}}
{{on-key "Enter" (perform this.createIntegrationTask)}}
id="new-integration-name"
class="gh-input mt1"
name="integration-name"
autofocus="autofocus"
{{autofocus}}
autocapitalize="off"
autocorrect="off"
data-test-input="new-integration-name">
<GhErrorMessage @errors={{this.integration.errors}} @property="name" data-test-error="new-integration-name" />
</GhFormGroup>
</fieldset>
{{#if this.errorMessage}}
<p class="error"><strong class="response">{{this.errorMessage}}</strong></p>
{{/if}}
</div>
<div class="modal-footer">
<button class="gh-btn" {{on "click" @close}} data-test-button="cancel-new-integration">
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText="Create"
@successText="Created"
@task={{this.createIntegrationTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="create-integration" />
</div>
</div>

View File

@ -0,0 +1,64 @@
import Component from '@glimmer/component';
import {A} from '@ember/array';
import {action} from '@ember/object';
import {isHostLimitError} from 'ghost-admin/services/ajax';
import {isInvalidError} from 'ember-ajax/errors';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
export default class NewCustomIntegrationModalComponent extends Component {
@service router;
@service store;
@tracked errorMessage;
constructor() {
super(...arguments);
this.integration = this.store.createRecord('integration');
}
willDestroy() {
super.willDestroy(...arguments);
this.integration.rollbackAttributes();
}
@action
updateName(inputEvent) {
this.integration.set('name', inputEvent.target.value);
this.integration.set('hasValidated', A());
this.integration.errors.clear();
}
@task({drop: true})
*createIntegrationTask() {
try {
const integration = yield this.integration.save();
this.router.transitionTo('settings.integration', integration);
return true;
} catch (error) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to model.errors automatically
if (error && isInvalidError(error)) {
let [firstError] = error.payload.errors;
let {message} = firstError;
if (message && message.match(/name/i)) {
this.integration.errors.add('name', message);
this.integration.hasValidated.pushObject('name');
return;
}
}
if (isHostLimitError(error)) {
this.errorMessage = error.payload.errors[0].context;
return;
}
// bubble up to the global error handler
if (error) {
throw error;
}
}
}
}

View File

@ -1,27 +0,0 @@
import Controller from '@ember/controller';
import {alias} from '@ember/object/computed';
import {computed} from '@ember/object';
export default Controller.extend({
integration: alias('model.integration'),
hostLimitError: alias('model.hostLimitError'),
showUpgradeModal: computed('hostLimitError', function () {
if (this.hostLimitError) {
return true;
}
return false;
}),
actions: {
save() {
return this.integration.save();
},
cancel() {
// 'new' route's dectivate hook takes care of rollback
this.transitionToRoute('settings.integrations');
}
}
});

View File

@ -1,31 +1,45 @@
import RSVP from 'rsvp';
import Route from '@ember/routing/route';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default Route.extend({
limit: service(),
export default class NewIntegrationRoute extends Route {
@service limit;
@service modals;
model() {
if (this.limit.limiter
&& this.limit.limiter.isLimited('customIntegrations')) {
return RSVP.hash({
integration: this.store.createRecord('integration'),
hostLimitError: this.limit.limiter.errorIfWouldGoOverLimit('customIntegrations')
.then(() => null)
.catch((error) => {
return error;
})
});
} else {
return RSVP.hash({
integration: this.store.createRecord('integration'),
hostLimitError: null
});
modal = null;
async model() {
if (this.limit.limiter?.isLimited('customIntegrations')) {
try {
await this.limit.limiter.errorIfWouldGoOverLimit('customIntegrations');
} catch (error) {
this.modal = this.modals.open('modals/limits/custom-integration', {
message: error.message
}, {
beforeClose: this.beforeModalClose
});
return;
}
}
},
this.modal = this.modals.open('modals/new-custom-integration', {}, {
beforeClose: this.beforeModalClose
});
}
deactivate() {
this._super(...arguments);
this.controller.integration.rollbackAttributes();
// ensure we don't try to redirect on modal close if we're already transitioning away
this.isLeaving = true;
this.modal?.close();
this.modal = null;
this.isLeaving = false;
}
});
@action
beforeModalClose() {
if (!this.isLeaving) {
this.transitionTo('settings.integrations');
}
}
}

View File

@ -13,7 +13,7 @@
</section>
</GhCanvasHeader>
<div class="gh-main-section">
<div class="gh-main-section" {{scroll-top}}>
<h4 class="gh-main-section-header small bn">Configuration</h4>
<section class="gh-main-section-block">
<div class="gh-main-section-content padding-top-s grey">

View File

@ -7,7 +7,7 @@
</h2>
</GhCanvasHeader>
<div class="gh-main-section">
<div class="gh-main-section" {{scroll-top}}>
<div class="integrations-directory">
<a class="id-item" href="https://ghost.org/integrations/disqus/" target="_blank" rel="noopener noreferrer">
<div class="id-item-logo id-disqus">

View File

@ -1,14 +0,0 @@
{{#if showUpgradeModal}}
<GhFullscreenModal @modal="upgrade-custom-integrations-host-limit"
@model={{hash
message=this.hostLimitError.message
}}
@close={{action "cancel"}}
@modifier="action wide" />
{{else}}
<GhFullscreenModal @modal="new-integration"
@model={{this.integration}}
@confirm={{action "save"}}
@close={{action "cancel"}}
@modifier="action wide" />
{{/if}}