Refactored integration webhook modals

refs https://github.com/TryGhost/Team/issues/1734
refs https://github.com/TryGhost/Team/issues/559
refs https://github.com/TryGhost/Ghost/issues/14101

- switches to newer modal patterns ready for later Ember upgrades
This commit is contained in:
Kevin Ansfield 2022-09-08 09:18:56 +01:00
parent 6fd1b08a3d
commit f654b24486
16 changed files with 317 additions and 304 deletions

View File

@ -1,15 +0,0 @@
<header class="modal-header">
<h1>Are you sure?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<p>
Deleting this webhook may prevent the integration from functioning.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{action "closeModal"}}><span>Cancel</span></button>
<GhTaskButton @buttonText="Delete Webhook" @successText="Deleted" @task={{this.deleteWebhook}} @class="gh-btn gh-btn-red gh-btn-icon" />
</div>

View File

@ -1,26 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
notifications: service(),
webhook: alias('model'),
actions: {
confirm() {
this.deleteWebhook.perform();
}
},
deleteWebhook: task(function* () {
try {
yield this.confirm();
} catch (error) {
this.notifications.showAPIError(error, {key: 'webhook.delete.failed'});
} finally {
this.send('closeModal');
}
}).drop()
});

View File

@ -1,103 +0,0 @@
<header class="modal-header" data-test-modal="webhook-form">
<h1 data-test-text="title">{{if this.webhook.isNew "New" "Edit"}} webhook</h1>
</header>
<button class="close" href title="Close" type="button" {{action "closeModal"}} {{action (optional this.noop) on="mouseDown"}}>
{{svg-jar "close"}}
</button>
<div class="modal-body">
<fieldset>
<GhFormGroup @errors={{this.webhook.errors}} @hasValidated={{this.webhook.hasValidated}} @property="name">
<label for="webhook-name" class="fw6">Name</label>
<GhTextInput
@value={{readonly this.webhook.name}}
@input={{action (mut this.webhook.name) value="target.value"}}
@focus-out={{action "validate" "name" target=this.webhook}}
@id="webhook-name"
@name="name"
@class="gh-input mt1"
@shouldFocus={{true}}
@autocapitalize="off"
@autocorrect="off"
data-test-input="webhook-name" />
<GhErrorMessage @errors={{this.webhook.errors}} @property="name" data-test-error="webhook-name" />
</GhFormGroup>
</fieldset>
<fieldset>
<GhFormGroup @errors={{this.webhook.errors}} @hasValidated={{this.webhook.hasValidated}} @property="event">
<label for="webhook-event" class="fw6">Event</label>
<span class="gh-select">
<OneWaySelect @value={{this.webhook.event}}
@options={{this.availableEvents}}
@optionValuePath="event"
@optionLabelPath="name"
@optionTargetPath="event"
@groupLabelPath="group"
@class="mt1"
@includeBlank={{true}}
@prompt="Select an event"
@update={{action "selectEvent"}}
@id="webhook-event"
@name="event"
data-test-select="webhook-event" />
{{svg-jar "arrow-down-small"}}
</span>
<GhErrorMessage @errors={{this.webhook.errors}} @property="event" data-test-error="webhook-event" />
</GhFormGroup>
</fieldset>
<fieldset>
<GhFormGroup @errors={{this.webhook.errors}} @hasValidated={{this.webhook.hasValidated}} @property="targetUrl">
<label for="webhook-targetUrl" class="fw6">Target URL</label>
<GhTextInput
@value={{readonly this.webhook.targetUrl}}
@input={{action (mut this.webhook.targetUrl) value="target.value"}}
@focus-out={{action "validate" "targetUrl" target=this.webhook}}
@id="webhook-targetUrl"
@name="targetUrl"
@placeholder="https://example.com"
@class="gh-input mt1"
@shouldFocus={{true}}
@autocapitalize="off"
@autocorrect="off"
data-test-input="webhook-targetUrl" />
<GhErrorMessage @errors={{this.webhook.errors}} @property="targetUrl" data-test-error="webhook-targetUrl" />
</GhFormGroup>
</fieldset>
{{#if (enable-developer-experiments)}}
<fieldset>
<GhFormGroup @errors={{this.webhook.errors}} @hasValidated={{this.webhook.hasValidated}} @property="secret">
<label for="webhook-secret" class="fw6">Secret</label>
<GhTextInput
@value={{readonly this.webhook.secret}}
@input={{action (mut this.webhook.secret) value="target.value"}}
@focus-out={{action "validate" "secret" target=this.webhook}}
@id="webhook-secret"
@name="secret"
@class="gh-input mt1"
@shouldFocus={{true}}
@autocapitalize="off"
@autocorrect="off"
data-test-input="webhook-secret" />
<GhErrorMessage @errors={{this.webhook.errors}} @property="secret" data-test-error="webhook-secret" />
</GhFormGroup>
</fieldset>
{{/if}}
{{#if this.error}}
<p class="red">{{this.error}}</p>
{{/if}}
</div>
<div class="modal-footer">
<button
class="gh-btn" data-test-button="cancel-webhook" type="button" {{action "closeModal"}}
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
{{action (optional this.noop) on="mouseDown"}}
>
<span>Cancel</span>
</button>
<GhTaskButton @buttonText={{this.buttonText}}
@successText={{this.successText}}
@task={{this.saveWebhook}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="save-webhook" />
</div>

View File

@ -1,80 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import Webhook from 'ghost-admin/models/webhook';
import {AVAILABLE_EVENTS} from 'ghost-admin/helpers/event-name';
import {alias} from '@ember/object/computed';
import {camelize} from '@ember/string';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
router: service(),
feature: service(),
availableEvents: null,
error: null,
buttonText: 'Save',
successText: 'Saved',
confirm() {},
webhook: alias('model'),
init() {
this._super(...arguments);
this.availableEvents = AVAILABLE_EVENTS;
},
didReceiveAttrs() {
this._super(...arguments);
if (this.webhook.isNew) {
this.set('buttonText', 'Create');
this.set('successText', 'Created');
}
},
actions: {
selectEvent(value) {
this.webhook.set('event', value);
this.webhook.validate({property: 'event'});
},
confirm() {
this.saveWebhook.perform();
}
},
saveWebhook: task(function* () {
this.set('error', null);
try {
let webhook = yield this.confirm();
let integration = yield webhook.get('integration');
this.router.transitionTo('settings.integration', integration);
} catch (e) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to model.errors automatically
if (e && e.payload && e.payload.errors) {
let attrs = Array.from(Webhook.attributes.keys());
e.payload.errors.forEach((error) => {
let {message, property = ''} = error;
property = camelize(property);
if (property && attrs.includes(property)) {
this.webhook.errors.add(property, message);
this.webhook.hasValidated.pushObject(property);
} else {
this.set('error', `Error: ${message}`);
}
});
return;
}
// bubble up to the global error handler
if (e) {
throw e;
}
}
})
});

View File

@ -0,0 +1,17 @@
<div class="modal-content" data-test-modal="delete-webhook">
<header class="modal-header">
<h1>Are you sure?</h1>
</header>
<a class="close" href="" role="button" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<p>
Deleting this webhook may prevent the integration from functioning.
</p>
</div>
<div class="modal-footer">
<button class="gh-btn" type="button" {{on "click" @close}}><span>Cancel</span></button>
<GhTaskButton @buttonText="Delete Webhook" @successText="Deleted" @task={{this.deleteWebhookTask}} @class="gh-btn gh-btn-red gh-btn-icon" />
</div>
</div>

View File

@ -0,0 +1,28 @@
import Component from '@glimmer/component';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class DeleteWebhookModal extends Component {
@service notifications;
@service router;
@task({drop: true})
*deleteWebhookTask() {
try {
const {webhook} = this.args.data;
if (webhook.isDeleted) {
return true;
}
yield webhook.destroyRecord();
this.notifications.closeAlerts('webhook.delete');
return true;
} catch (error) {
this.notifications.showAPIError(error, {key: 'webhook.delete.failed'});
} finally {
this.args.close();
}
}
}

View File

@ -0,0 +1,110 @@
<div class="modal-content" data-test-modal="webhook-form">
<header class="modal-header" data-test-modal="webhook-form">
<h1 data-test-text="title">{{if @data.webhook.isNew "New" "Edit"}} webhook</h1>
</header>
{{!-- template-lint-disable no-down-event-binding --}}
<button class="close" href title="Close" type="button" {{on "click" @close}} {{on "mousedown" this.noop}}>
{{svg-jar "close"}}
</button>
<div class="modal-body">
<fieldset>
<GhFormGroup @errors={{@data.webhook.errors}} @hasValidated={{@data.webhook.hasValidated}} @property="name">
<label for="webhook-name" class="fw6">Name</label>
<input
type="text"
value={{@data.webhook.name}}
id="webhook-name"
class="gh-input mt1"
autocapitalize="off"
autocorrect="off"
{{autofocus}}
{{on "input" (fn this.setProperty "name")}}
{{on "blur" (fn this.validate "name")}}
data-test-input="webhook-name"
/>
<GhErrorMessage @errors={{@data.webhook.errors}} @property="name" data-test-error="webhook-name" />
</GhFormGroup>
</fieldset>
<fieldset>
<GhFormGroup @errors={{@data.webhook.errors}} @hasValidated={{@data.webhook.hasValidated}} @property="event">
<label for="webhook-event" class="fw6">Event</label>
<span class="gh-select">
<OneWaySelect
@value={{@data.webhook.event}}
@options={{this.availableEvents}}
@optionValuePath="event"
@optionLabelPath="name"
@optionTargetPath="event"
@groupLabelPath="group"
@class="mt1"
@includeBlank={{true}}
@prompt="Select an event"
@update={{this.selectEvent}}
@id="webhook-event"
@name="event"
data-test-select="webhook-event" />
{{svg-jar "arrow-down-small"}}
</span>
<GhErrorMessage @errors={{@data.webhook.errors}} @property="event" data-test-error="webhook-event" />
</GhFormGroup>
</fieldset>
<fieldset>
<GhFormGroup @errors={{@data.webhook.errors}} @hasValidated={{@data.webhook.hasValidated}} @property="targetUrl">
<label for="webhook-targetUrl" class="fw6">Target URL</label>
<input
type="text"
value={{@data.webhook.targetUrl}}
id="webhook-targetUrl"
class="gh-input mt1"
placeholder="https://example.com"
autocapitalize="off"
autocorrect="off"
{{on "input" (fn this.setProperty "targetUrl")}}
{{on "blur" (fn this.validate "targetUrl")}}
data-test-input="webhook-targetUrl"
/>
<GhErrorMessage @errors={{@data.webhook.errors}} @property="targetUrl" data-test-error="webhook-targetUrl" />
</GhFormGroup>
</fieldset>
{{#if (enable-developer-experiments)}}
<fieldset>
<GhFormGroup @errors={{@data.webhook.errors}} @hasValidated={{@data.webhook.hasValidated}} @property="secret">
<label for="webhook-secret" class="fw6">Secret</label>
<input
type="text"
value={{@data.webhook.secret}}
id="webhook-secret"
class="gh-input mt1"
placeholder="https://example.com"
autocapitalize="off"
autocorrect="off"
{{on "input" (fn this.setProperty "secret")}}
{{on "blur" (fn this.validate "secret")}}
data-test-input="webhook-secret"
/>
<GhErrorMessage @errors={{@data.webhook.errors}} @property="secret" data-test-error="webhook-secret" />
</GhFormGroup>
</fieldset>
{{/if}}
{{#if this.error}}
<p class="red">{{this.error}}</p>
{{/if}}
</div>
<div class="modal-footer">
<button
class="gh-btn" data-test-button="cancel-webhook" type="button" {{on "click" @close}}
{{!-- disable mouseDown so it doesn't trigger focus-out validations --}}
{{on "mousedown" this.noop}}
>
<span>Cancel</span>
</button>
<GhTaskButton
@buttonText={{this.buttonText}}
@successText={{this.successText}}
@task={{this.saveWebhookTask}}
@class="gh-btn gh-btn-black gh-btn-icon"
data-test-button="save-webhook" />
</div>
</div>

View File

@ -0,0 +1,81 @@
import Component from '@glimmer/component';
import Webhook from 'ghost-admin/models/webhook';
import {AVAILABLE_EVENTS} from 'ghost-admin/helpers/event-name';
import {action} from '@ember/object';
import {camelize} from '@ember/string';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
import {tracked} from '@glimmer/tracking';
export default class WebhookFormModal extends Component {
@service notifications;
@service router;
availableEvents = AVAILABLE_EVENTS;
buttonText = this.args.data.webhook.isNew ? 'Create' : 'Save';
successText = this.args.data.webhook.isNew ? 'Created' : 'Saved';
@tracked error = null;
get webhook() {
return this.args.data.webhook;
}
@action
setProperty(property, event) {
this.webhook[property] = event.target.value;
}
@action
selectEvent(value) {
this.webhook.event = value;
this.webhook.validate({property: 'event'});
}
@action
validate(property) {
return this.webhook.validate({property});
}
@action
noop(event) {
event.preventDefault();
}
@task({drop: true})
*saveWebhookTask() {
this.error = null;
try {
const webhook = yield this.webhook.save();
const integration = yield webhook.integration;
this.router.transitionTo('settings.integration', integration);
return true;
} catch (e) {
// TODO: server-side validation errors should be serialized
// properly so that errors are added to model.errors automatically
if (e && e.payload && e.payload.errors) {
const attrs = Array.from(Webhook.attributes.keys());
e.payload.errors.forEach((error) => {
let {message, property = ''} = error;
property = camelize(property);
if (property && attrs.includes(property)) {
this.webhook.errors.add(property, message);
this.webhook.hasValidated.pushObject(property);
} else {
this.error = `Error: ${message}`;
}
});
return;
}
// bubble up to the global error handler
if (e) {
throw e;
}
}
}
}

View File

@ -1,5 +1,6 @@
import Controller from '@ember/controller';
import DeleteIntegrationModal from '../../components/settings/integrations/delete-integration-modal';
import DeleteWebhookModal from '../../components/settings/integrations/delete-webhook-modal';
import config from 'ghost-admin/config/environment';
import copyTextToClipboard from 'ghost-admin/utils/copy-text-to-clipboard';
import {
@ -24,7 +25,6 @@ export default class IntegrationController extends Controller {
@tracked showUnsavedChangesModal = false;
@tracked selectedApiKey = null;
@tracked isApiKeyRegenerated = false;
@tracked webhookToDelete;
constructor() {
super(...arguments);
@ -180,13 +180,7 @@ export default class IntegrationController extends Controller {
@action
confirmWebhookDeletion(webhook, event) {
event?.preventDefault();
this.webhookToDelete = webhook;
}
@action
cancelWebhookDeletion(event) {
event?.preventDefault();
this.webhookToDelete = null;
return this.modals.open(DeleteWebhookModal, {webhook});
}
@action

View File

@ -1,28 +1,4 @@
import Controller from '@ember/controller';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {alias} from '@ember/object/computed';
@classic
export default class EditController extends Controller {
@alias('model')
webhook;
@action
save() {
return this.webhook.save();
}
@action
cancel() {
// 'new' route's dectivate hook takes care of rollback
return this.webhook.get('integration').then((integration) => {
this.transitionToRoute('settings.integration', integration);
});
}
reset() {
this.webhook.rollbackAttributes();
this.webhook.errors.clear();
}
}

View File

@ -1,23 +1,4 @@
import Controller from '@ember/controller';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {alias} from '@ember/object/computed';
@classic
export default class NewController extends Controller {
@alias('model')
webhook;
@action
save() {
return this.webhook.save();
}
@action
cancel() {
// 'new' route's dectivate hook takes care of rollback
return this.webhook.get('integration').then((integration) => {
this.transitionToRoute('settings.integration', integration);
});
}
export default class NewWebhookController extends Controller {
}

View File

@ -1,14 +1,49 @@
import AdminRoute from 'ghost-admin/routes/admin';
import WebhookFormModal from '../../../../components/settings/integrations/webhook-form-modal';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class EditWebhookRoute extends AdminRoute {
@service modals;
@service router;
webhook = null;
modal = null;
get integration() {
return this.modelFor('settings.integration');
}
export default class EditRoute extends AdminRoute {
model(params) {
let integration = this.modelFor('settings.integration');
let webhook = integration.webhooks.findBy('id', params.webhook_id);
return webhook;
return this.integration.webhooks.findBy('id', params.webhook_id);
}
setupController(controller, model) {
this.webhook = model;
this.modal = this.modals.open(WebhookFormModal, {
webhook: this.webhook
}, {
beforeClose: this.beforeModalClose
});
}
deactivate() {
super.deactivate(...arguments);
this.controller.reset();
this.webhook?.errors.clear();
this.webhook?.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.modal && !this.isLeaving) {
this.router.transitionTo('settings.integration', this.integration);
}
}
}

View File

@ -1,13 +1,45 @@
import AdminRoute from 'ghost-admin/routes/admin';
import WebhookFormModal from '../../../../components/settings/integrations/webhook-form-modal';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
export default class NewWebhookRoute extends AdminRoute {
@service modals;
@service router;
webhook = null;
modal = null;
get integration() {
return this.modelFor('settings.integration');
}
export default class NewRoute extends AdminRoute {
model() {
let integration = this.modelFor('settings.integration');
return this.store.createRecord('webhook', {integration});
this.webhook = this.store.createRecord('webhook', {integration: this.integration});
this.modal = this.modals.open(WebhookFormModal, {
webhook: this.webhook
}, {
beforeClose: this.beforeModalClose
});
}
deactivate() {
super.deactivate(...arguments);
this.controller.webhook.rollbackAttributes();
this.webhook?.errors.clear();
this.webhook?.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.modal && !this.isLeaving) {
this.router.transitionTo('settings.integration', this.integration);
}
}
}

View File

@ -290,11 +290,4 @@
@modifier="action wide" />
{{/if}}
{{#if this.webhookToDelete}}
<GhFullscreenModal @modal="delete-webhook"
@confirm={{this.deleteWebhook}}
@close={{this.cancelWebhookDeletion}}
@modifier="action wide" />
{{/if}}
{{outlet}}

View File

@ -1,5 +0,0 @@
<GhFullscreenModal @modal="webhook-form"
@model={{this.webhook}}
@confirm={{action "save"}}
@close={{action "cancel"}}
@modifier="action wide" />

View File

@ -1,5 +0,0 @@
<GhFullscreenModal @modal="webhook-form"
@model={{this.webhook}}
@confirm={{action "save"}}
@close={{action "cancel"}}
@modifier="action wide" />