mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 06:35:49 +03:00
Added initial custom integrations UI (#1051)
refs https://github.com/TryGhost/Ghost/issues/9865, https://github.com/TryGhost/Ghost/issues/9942 - `integration`, `api-key`, and `webhook` models and respective mirage mocks - moves integration routes around to match ember's concept of nested routes (nested routes reflect nested UI not nested URLs) - adds custom integrations list to integrations screen - adds custom integration screen - allow editing of integration details - show list of webhooks - webhook creation modal NB: the `enableDeveloperExperiments` flag needs to be enabled in the `config.development.json` file for the custom integrations UI to be displayed until it's out of development.
This commit is contained in:
parent
faf489fcd9
commit
5047b9f3d7
@ -1,6 +1,7 @@
|
||||
import BaseAdapter from 'ghost-admin/adapters/base';
|
||||
import {get} from '@ember/object';
|
||||
import {isNone} from '@ember/utils';
|
||||
import {underscore} from '@ember/string';
|
||||
|
||||
// EmbeddedRelationAdapter will augment the query object in calls made to
|
||||
// DS.Store#findRecord, findAll, query, and queryRecord with the correct "includes"
|
||||
@ -76,7 +77,7 @@ export default BaseAdapter.extend({
|
||||
let url = this.buildURL(modelName, id, snapshot, requestType, query);
|
||||
|
||||
if (includes.length) {
|
||||
url += `?include=${includes.join(',')}`;
|
||||
url += `?include=${includes.map(underscore).join(',')}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
@ -92,7 +93,7 @@ export default BaseAdapter.extend({
|
||||
if (typeof options === 'string' || typeof options === 'number') {
|
||||
query = {};
|
||||
query.id = options;
|
||||
query.include = toInclude.join(',');
|
||||
query.include = toInclude.map(underscore).join(',');
|
||||
} else if (typeof options === 'object' || isNone(options)) {
|
||||
// If this is a find all (no existing query object) build one and attach
|
||||
// the includes.
|
||||
|
51
ghost/admin/app/components/modal-new-integration.js
Normal file
51
ghost/admin/app/components/modal-new-integration.js
Normal file
@ -0,0 +1,51 @@
|
||||
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||
import {alias} from '@ember/object/computed';
|
||||
import {A as emberA} from '@ember/array';
|
||||
import {isInvalidError} from 'ember-ajax/errors';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency';
|
||||
|
||||
export default ModalComponent.extend({
|
||||
router: service(),
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// bubble up to the global error handler
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}).drop()
|
||||
});
|
68
ghost/admin/app/components/modal-new-webhook.js
Normal file
68
ghost/admin/app/components/modal-new-webhook.js
Normal file
@ -0,0 +1,68 @@
|
||||
import ModalComponent from 'ghost-admin/components/modal-base';
|
||||
import Webhook from 'ghost-admin/models/webhook';
|
||||
import {alias} from '@ember/object/computed';
|
||||
import {camelize} from '@ember/string';
|
||||
import {isInvalidError} from 'ember-ajax/errors';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency';
|
||||
|
||||
export default ModalComponent.extend({
|
||||
router: service(),
|
||||
|
||||
availableEvents: null,
|
||||
|
||||
confirm() {},
|
||||
|
||||
webhook: alias('model'),
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this.availableEvents = [
|
||||
{event: 'site.changed', name: 'Site Changed (rebuild)'},
|
||||
{event: 'subscriber.added', name: 'Subscriber Added'},
|
||||
{event: 'subscriber.deleted', name: 'Subscriber Deleted'}
|
||||
];
|
||||
},
|
||||
|
||||
actions: {
|
||||
selectEvent(value) {
|
||||
this.webhook.set('event', value);
|
||||
this.webhook.validate({property: 'event'});
|
||||
},
|
||||
|
||||
confirm() {
|
||||
this.createWebhook.perform();
|
||||
}
|
||||
},
|
||||
|
||||
createWebhook: task(function* () {
|
||||
try {
|
||||
let webhook = yield this.confirm();
|
||||
let integration = yield webhook.get('integration');
|
||||
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 attrs = Array.from(Webhook.attributes.keys());
|
||||
|
||||
error.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);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// bubble up to the global error handler
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
87
ghost/admin/app/controllers/settings/integration.js
Normal file
87
ghost/admin/app/controllers/settings/integration.js
Normal file
@ -0,0 +1,87 @@
|
||||
import Controller from '@ember/controller';
|
||||
import {alias} from '@ember/object/computed';
|
||||
import {computed} from '@ember/object';
|
||||
import {task} from 'ember-concurrency';
|
||||
|
||||
export default Controller.extend({
|
||||
integration: alias('model'),
|
||||
|
||||
allWebhooks: computed(function () {
|
||||
return this.store.peekAll('webhook');
|
||||
}),
|
||||
|
||||
filteredWebhooks: computed('allWebhooks.@each.{isNew,isDeleted}', function () {
|
||||
return this.allWebhooks.filter((webhook) => {
|
||||
let matchesIntegration = webhook.belongsTo('integration').id() === this.integration.id;
|
||||
|
||||
return matchesIntegration
|
||||
&& !webhook.isNew
|
||||
&& !webhook.isDeleted;
|
||||
});
|
||||
}),
|
||||
|
||||
actions: {
|
||||
save() {
|
||||
return this.save.perform();
|
||||
},
|
||||
|
||||
copyContentKey() {
|
||||
this._copyInputTextToClipboard('input#content_key');
|
||||
},
|
||||
|
||||
copyAdminKey() {
|
||||
this._copyInputTextToClipboard('input#admin_key');
|
||||
},
|
||||
|
||||
toggleUnsavedChangesModal(transition) {
|
||||
let leaveTransition = this.leaveScreenTransition;
|
||||
|
||||
if (!transition && this.showUnsavedChangesModal) {
|
||||
this.set('leaveScreenTransition', null);
|
||||
this.set('showUnsavedChangesModal', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
|
||||
this.set('leaveScreenTransition', transition);
|
||||
|
||||
// if a save is running, wait for it to finish then transition
|
||||
if (this.save.isRunning) {
|
||||
return this.save.last.then(() => {
|
||||
transition.retry();
|
||||
});
|
||||
}
|
||||
|
||||
// we genuinely have unsaved data, show the modal
|
||||
this.set('showUnsavedChangesModal', true);
|
||||
}
|
||||
},
|
||||
|
||||
leaveScreen() {
|
||||
let transition = this.leaveScreenTransition;
|
||||
|
||||
if (!transition) {
|
||||
this.notifications.showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
|
||||
return;
|
||||
}
|
||||
|
||||
// roll back changes on model props
|
||||
this.integration.rollbackAttributes();
|
||||
|
||||
return transition.retry();
|
||||
}
|
||||
},
|
||||
|
||||
save: task(function* () {
|
||||
return yield this.integration.save();
|
||||
}),
|
||||
|
||||
_copyInputTextToClipboard(selector) {
|
||||
let input = document.querySelector(selector);
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
input.disabled = true;
|
||||
}
|
||||
});
|
@ -0,0 +1,19 @@
|
||||
import Controller from '@ember/controller';
|
||||
import {alias} from '@ember/object/computed';
|
||||
|
||||
export default Controller.extend({
|
||||
webhook: alias('model'),
|
||||
|
||||
actions: {
|
||||
save() {
|
||||
return this.webhook.save();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
// 'new' route's dectivate hook takes care of rollback
|
||||
return this.webhook.get('integration').then((integration) => {
|
||||
this.transitionToRoute('settings.integration', integration);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
@ -1,7 +1,31 @@
|
||||
/* eslint-disable ghost/ember/alias-model-in-controller */
|
||||
import Controller from '@ember/controller';
|
||||
import {computed} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency';
|
||||
|
||||
export default Controller.extend({
|
||||
settings: service()
|
||||
config: service(),
|
||||
settings: service(),
|
||||
store: service(),
|
||||
|
||||
_allIntegrations: null,
|
||||
|
||||
init() {
|
||||
this._super(...arguments);
|
||||
this._allIntegrations = this.store.peekAll('integration');
|
||||
},
|
||||
|
||||
// filter over the live query so that the list is automatically updated
|
||||
// as integrations are added/removed
|
||||
integrations: computed('_allIntegrations.@each.isNew', function () {
|
||||
return this._allIntegrations.rejectBy('isNew', true);
|
||||
}),
|
||||
|
||||
// use ember-concurrency so that we can use the derived state to show
|
||||
// a spinner only in the integrations list and avoid delaying the whole
|
||||
// screen display
|
||||
fetchIntegrations: task(function* () {
|
||||
return yield this.store.findAll('integration');
|
||||
})
|
||||
});
|
||||
|
17
ghost/admin/app/controllers/settings/integrations/new.js
Normal file
17
ghost/admin/app/controllers/settings/integrations/new.js
Normal file
@ -0,0 +1,17 @@
|
||||
import Controller from '@ember/controller';
|
||||
import {alias} from '@ember/object/computed';
|
||||
|
||||
export default Controller.extend({
|
||||
integration: alias('model'),
|
||||
|
||||
actions: {
|
||||
save() {
|
||||
return this.integration.save();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
// 'new' route's dectivate hook takes care of rollback
|
||||
this.transitionToRoute('settings.integrations');
|
||||
}
|
||||
}
|
||||
});
|
11
ghost/admin/app/helpers/integration-icon-style.js
Normal file
11
ghost/admin/app/helpers/integration-icon-style.js
Normal file
@ -0,0 +1,11 @@
|
||||
import {helper} from '@ember/component/helper';
|
||||
import {htmlSafe} from '@ember/string';
|
||||
|
||||
export function integrationLogoStyle([integration]/*, hash*/) {
|
||||
if (integration.iconImage) {
|
||||
let style = `background-image:url(${integration.iconImage});background-size:45px;`;
|
||||
return htmlSafe(style);
|
||||
}
|
||||
}
|
||||
|
||||
export default helper(integrationLogoStyle);
|
@ -1,4 +1,5 @@
|
||||
import DS from 'ember-data';
|
||||
import IntegrationValidator from 'ghost-admin/validators/integration';
|
||||
import InviteUserValidator from 'ghost-admin/validators/invite-user';
|
||||
import Mixin from '@ember/object/mixin';
|
||||
import Model from 'ember-data/model';
|
||||
@ -14,6 +15,7 @@ import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration'
|
||||
import SubscriberValidator from 'ghost-admin/validators/subscriber';
|
||||
import TagSettingsValidator from 'ghost-admin/validators/tag-settings';
|
||||
import UserValidator from 'ghost-admin/validators/user';
|
||||
import WebhookValidator from 'ghost-admin/validators/webhook';
|
||||
import {A as emberA, isArray as isEmberArray} from '@ember/array';
|
||||
|
||||
const {Errors} = DS;
|
||||
@ -42,7 +44,9 @@ export default Mixin.create({
|
||||
slackIntegration: SlackIntegrationValidator,
|
||||
subscriber: SubscriberValidator,
|
||||
tag: TagSettingsValidator,
|
||||
user: UserValidator
|
||||
user: UserValidator,
|
||||
integration: IntegrationValidator,
|
||||
webhook: WebhookValidator
|
||||
},
|
||||
|
||||
// This adds the Errors object to the validation engine, and shouldn't affect
|
||||
|
15
ghost/admin/app/models/api-key.js
Normal file
15
ghost/admin/app/models/api-key.js
Normal file
@ -0,0 +1,15 @@
|
||||
import Model from 'ember-data/model';
|
||||
import attr from 'ember-data/attr';
|
||||
import {belongsTo} from 'ember-data/relationships';
|
||||
|
||||
export default Model.extend({
|
||||
type: attr('string'),
|
||||
secret: attr('string'),
|
||||
lastSeenAtUTC: attr('moment-utc'),
|
||||
createdAtUTC: attr('moment-utc'),
|
||||
createdBy: attr('number'),
|
||||
updatedAtUTC: attr('moment-utc'),
|
||||
updatedBy: attr('number'),
|
||||
|
||||
integration: belongsTo('integration')
|
||||
});
|
35
ghost/admin/app/models/integration.js
Normal file
35
ghost/admin/app/models/integration.js
Normal file
@ -0,0 +1,35 @@
|
||||
import Model from 'ember-data/model';
|
||||
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
|
||||
import attr from 'ember-data/attr';
|
||||
import {computed} from '@ember/object';
|
||||
import {hasMany} from 'ember-data/relationships';
|
||||
|
||||
export default Model.extend(ValidationEngine, {
|
||||
validationType: 'integration',
|
||||
|
||||
name: attr('string'),
|
||||
slug: attr('string'),
|
||||
iconImage: attr('string'),
|
||||
description: attr('string'),
|
||||
createdAtUTC: attr('moment-utc'),
|
||||
createdBy: attr('number'),
|
||||
updatedAtUTC: attr('moment-utc'),
|
||||
updatedBy: attr('number'),
|
||||
|
||||
apiKeys: hasMany('api-key', {
|
||||
embedded: 'always',
|
||||
async: false
|
||||
}),
|
||||
webhooks: hasMany('webhook', {
|
||||
embedded: 'always',
|
||||
async: false
|
||||
}),
|
||||
|
||||
adminKey: computed('apiKeys.[]', function () {
|
||||
return this.apiKeys.findBy('type', 'admin');
|
||||
}),
|
||||
|
||||
contentKey: computed('apiKeys.[]', function () {
|
||||
return this.apiKeys.findBy('type', 'content');
|
||||
})
|
||||
});
|
20
ghost/admin/app/models/webhook.js
Normal file
20
ghost/admin/app/models/webhook.js
Normal file
@ -0,0 +1,20 @@
|
||||
import Model from 'ember-data/model';
|
||||
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
|
||||
import attr from 'ember-data/attr';
|
||||
import {belongsTo} from 'ember-data/relationships';
|
||||
|
||||
export default Model.extend(ValidationEngine, {
|
||||
validationType: 'webhook',
|
||||
|
||||
name: attr('string'),
|
||||
event: attr('string'),
|
||||
targetUrl: attr('string'),
|
||||
secret: attr('string'),
|
||||
lastTriggeredAtUTC: attr('moment-utc'),
|
||||
createdAtUTC: attr('moment-utc'),
|
||||
createdBy: attr('number'),
|
||||
updatedAtUTC: attr('moment-utc'),
|
||||
updatedBy: attr('number'),
|
||||
|
||||
integration: belongsTo('integration')
|
||||
});
|
@ -52,8 +52,12 @@ Router.map(function () {
|
||||
this.route('settings.design', {path: '/settings/design'}, function () {
|
||||
this.route('uploadtheme');
|
||||
});
|
||||
|
||||
this.route('settings.integrations', {path: '/settings/integrations'});
|
||||
this.route('settings.integrations', {path: '/settings/integrations'}, function () {
|
||||
this.route('new');
|
||||
});
|
||||
this.route('settings.integration', {path: '/settings/integrations/:integration_id'}, function () {
|
||||
this.route('webhooks.new', {path: 'webhooks/new'});
|
||||
});
|
||||
this.route('settings.integrations.slack', {path: '/settings/integrations/slack'});
|
||||
this.route('settings.integrations.amp', {path: '/settings/integrations/amp'});
|
||||
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});
|
||||
|
56
ghost/admin/app/routes/settings/integration.js
Normal file
56
ghost/admin/app/routes/settings/integration.js
Normal file
@ -0,0 +1,56 @@
|
||||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
|
||||
import styleBody from 'ghost-admin/mixins/style-body';
|
||||
|
||||
export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
|
||||
titleToken: 'Settings - Integrations',
|
||||
classNames: ['settings-view-integration'],
|
||||
|
||||
beforeModel() {
|
||||
this._super(...arguments);
|
||||
return this.get('session.user')
|
||||
.then(this.transitionAuthor())
|
||||
.then(this.transitionEditor());
|
||||
},
|
||||
|
||||
model(params, transition) {
|
||||
let integration = this.store.peekRecord('integration', params.integration_id);
|
||||
|
||||
if (integration) {
|
||||
return integration;
|
||||
}
|
||||
|
||||
// integration is not already in the store so use the integrations controller
|
||||
// to fetch all of them and pull out the one we're interested in. Using the
|
||||
// integrations controller means it's possible to navigate back to the integrations
|
||||
// screen without triggering a loading state
|
||||
return this.controllerFor('settings.integrations')
|
||||
.fetchIntegrations.perform()
|
||||
.then((integrations) => {
|
||||
let integration = integrations.findBy('id', params.integration_id);
|
||||
|
||||
if (!integration) {
|
||||
let path = transition.intent.url.replace(/^\//, '');
|
||||
return this.replaceWith('error404', {path, status: 404});
|
||||
}
|
||||
|
||||
return integration;
|
||||
});
|
||||
},
|
||||
|
||||
actions: {
|
||||
save() {
|
||||
this.controller.send('save');
|
||||
},
|
||||
|
||||
willTransition(transition) {
|
||||
let {controller} = this;
|
||||
|
||||
if (controller.integration.hasDirtyAttributes) {
|
||||
transition.abort();
|
||||
controller.send('toggleUnsavedChangesModal', transition);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
13
ghost/admin/app/routes/settings/integration/webhooks/new.js
Normal file
13
ghost/admin/app/routes/settings/integration/webhooks/new.js
Normal file
@ -0,0 +1,13 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
model() {
|
||||
let integration = this.modelFor('settings.integration');
|
||||
return this.store.createRecord('webhook', {integration});
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
this._super(...arguments);
|
||||
this.controller.webhook.rollbackAttributes();
|
||||
}
|
||||
});
|
@ -14,5 +14,11 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
|
||||
return this.get('session.user')
|
||||
.then(this.transitionAuthor())
|
||||
.then(this.transitionEditor());
|
||||
},
|
||||
|
||||
setupController(controller) {
|
||||
// kick off the background fetch of integrations so that we can
|
||||
// show the screen immediately
|
||||
controller.fetchIntegrations.perform();
|
||||
}
|
||||
});
|
||||
|
12
ghost/admin/app/routes/settings/integrations/new.js
Normal file
12
ghost/admin/app/routes/settings/integrations/new.js
Normal file
@ -0,0 +1,12 @@
|
||||
import Route from '@ember/routing/route';
|
||||
|
||||
export default Route.extend({
|
||||
model() {
|
||||
return this.store.createRecord('integration');
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
this._super(...arguments);
|
||||
this.controller.integration.rollbackAttributes();
|
||||
}
|
||||
});
|
9
ghost/admin/app/serializers/api-key.js
Normal file
9
ghost/admin/app/serializers/api-key.js
Normal file
@ -0,0 +1,9 @@
|
||||
import ApplicationSerializer from './application';
|
||||
|
||||
export default ApplicationSerializer.extend({
|
||||
attrs: {
|
||||
lastSeenAtUTC: {key: 'last_seen_at'},
|
||||
createdAtUTC: {key: 'created_at'},
|
||||
updatedAtUTC: {key: 'updated_at'}
|
||||
}
|
||||
});
|
11
ghost/admin/app/serializers/integration.js
Normal file
11
ghost/admin/app/serializers/integration.js
Normal file
@ -0,0 +1,11 @@
|
||||
import ApplicationSerializer from './application';
|
||||
import EmbeddedRecordsMixin from 'ember-data/serializers/embedded-records-mixin';
|
||||
|
||||
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
|
||||
attrs: {
|
||||
apiKeys: {embedded: 'always'},
|
||||
webhooks: {embedded: 'always'},
|
||||
createdAtUTC: {key: 'created_at'},
|
||||
updatedAtUTC: {key: 'updated_at'}
|
||||
}
|
||||
});
|
9
ghost/admin/app/serializers/webhook.js
Normal file
9
ghost/admin/app/serializers/webhook.js
Normal file
@ -0,0 +1,9 @@
|
||||
import ApplicationSerializer from './application';
|
||||
|
||||
export default ApplicationSerializer.extend({
|
||||
attrs: {
|
||||
lastTriggeredAtUTC: {key: 'last_triggered_at'},
|
||||
createdAtUTC: {key: 'created_at'},
|
||||
updatedAtUTC: {key: 'updated_at'}
|
||||
}
|
||||
});
|
@ -148,6 +148,7 @@ select {
|
||||
|
||||
.gh-input.error,
|
||||
.error .gh-input,
|
||||
.error .gh-select select,
|
||||
.error .ember-power-select-multiple-trigger,
|
||||
.gh-select.error,
|
||||
select.error {
|
||||
|
@ -1,4 +1,4 @@
|
||||
<header class="modal-header">
|
||||
<header class="modal-header" data-modal="unsaved-settings">
|
||||
<h1>Are you sure you want to leave this page?</h1>
|
||||
</header>
|
||||
<a class="close" href="" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
|
||||
|
@ -0,0 +1,35 @@
|
||||
<header class="modal-header" data-test-modal="new-integration">
|
||||
<h1>New custom integration</h1>
|
||||
</header>
|
||||
<button class="close" href="" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}</button>
|
||||
|
||||
<div class="modal-body">
|
||||
<fieldset>
|
||||
{{#gh-form-group errors=integration.errors hasValidated=integration.hasValidated property="name"}}
|
||||
<label for="new-integration-name">Name</label>
|
||||
<input type="text"
|
||||
value={{integration.name}}
|
||||
oninput={{action "updateName" value="target.value"}}
|
||||
id="new-integration-name"
|
||||
class="gh-input"
|
||||
placeholder="Integration name..."
|
||||
name="integration-name"
|
||||
autofocus="autofocus"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
data-test-input="new-integration-name">
|
||||
{{gh-error-message errors=integration.errors property="name" data-test-error="new-integration-name"}}
|
||||
{{/gh-form-group}}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button {{action "closeModal"}} class="gh-btn" data-test-button="cancel-new-integration">
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
{{gh-task-button "Create"
|
||||
successText="Created"
|
||||
task=createIntegration
|
||||
class="gh-btn gh-btn-green gh-btn-icon"
|
||||
data-test-button="create-integration"}}
|
||||
</div>
|
92
ghost/admin/app/templates/components/modal-new-webhook.hbs
Normal file
92
ghost/admin/app/templates/components/modal-new-webhook.hbs
Normal file
@ -0,0 +1,92 @@
|
||||
<header class="modal-header" data-test-modal="new-webhook">
|
||||
<h1>New webhook</h1>
|
||||
</header>
|
||||
<button class="close" href="" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}</button>
|
||||
|
||||
<div class="modal-body">
|
||||
<fieldset>
|
||||
{{#gh-form-group errors=webhook.errors hasValidated=webhook.hasValidated property="name"}}
|
||||
<label for="new-webhook-name">Name</label>
|
||||
{{gh-text-input
|
||||
value=(readonly webhook.name)
|
||||
input=(action (mut webhook.name) value="target.value")
|
||||
focus-out=(action "validate" "name" target=webhook)
|
||||
id="new-webhook-name"
|
||||
name="name"
|
||||
class="gh-input"
|
||||
placeholder="Webhook name..."
|
||||
autofocus="autofocus"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
data-test-input="new-webhook-name"}}
|
||||
{{gh-error-message errors=webhook.errors property="name" data-test-error="new-webhook-name"}}
|
||||
{{/gh-form-group}}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
{{#gh-form-group errors=webhook.errors hasValidated=webhook.hasValidated property="event"}}
|
||||
<label for="new-webhook-event">Event</label>
|
||||
<span class="gh-select">
|
||||
{{one-way-select webhook.event
|
||||
options=availableEvents
|
||||
optionValuePath="event"
|
||||
optionLabelPath="name"
|
||||
optionTargetPath="event"
|
||||
includeBlank=true
|
||||
prompt="Select an event:"
|
||||
update=(action "selectEvent")
|
||||
id="new-webhook-event"
|
||||
name="event"
|
||||
data-test-select="new-webhook-event"}}
|
||||
{{svg-jar "arrow-down-small"}}
|
||||
</span>
|
||||
{{gh-error-message errors=webhook.errors property="event" data-test-error="new-webhook-event"}}
|
||||
{{/gh-form-group}}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
{{#gh-form-group errors=webhook.errors hasValidated=webhook.hasValidated property="targetUrl"}}
|
||||
<label for="new-webhook-targetUrl">Target URL</label>
|
||||
{{gh-text-input
|
||||
value=(readonly webhook.targetUrl)
|
||||
input=(action (mut webhook.targetUrl) value="target.value")
|
||||
focus-out=(action "validate" "targetUrl" target=webhook)
|
||||
id="new-webhook-targetUrl"
|
||||
name="targetUrl"
|
||||
class="gh-input"
|
||||
placeholder="Webhook target URL..."
|
||||
autofocus="autofocus"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
data-test-input="new-webhook-targetUrl"}}
|
||||
{{gh-error-message errors=webhook.errors property="targetUrl" data-test-error="new-webhook-targetUrl"}}
|
||||
{{/gh-form-group}}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
{{#gh-form-group errors=webhook.errors hasValidated=webhook.hasValidated property="secret"}}
|
||||
<label for="new-webhook-secret">Secret</label>
|
||||
{{gh-text-input
|
||||
value=(readonly webhook.secret)
|
||||
oninput=(action (mut webhook.secret) value="target.value")
|
||||
focus-out=(action "validate" "secret" target=webhook)
|
||||
id="new-webhook-secret"
|
||||
name="secret"
|
||||
class="gh-input"
|
||||
placeholder="Webhook secret..."
|
||||
autofocus="autofocus"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
data-test-input="new-webhook-secret"}}
|
||||
{{gh-error-message errors=webhook.errors property="secret" data-test-error="new-webhook-secret"}}
|
||||
{{/gh-form-group}}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button {{action "closeModal"}} class="gh-btn" data-test-button="cancel-new-webhook">
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
{{gh-task-button "Create"
|
||||
successText="Created"
|
||||
task=createWebhook
|
||||
class="gh-btn gh-btn-green gh-btn-icon"
|
||||
data-test-button="create-webhook"}}
|
||||
</div>
|
11
ghost/admin/app/templates/settings/integration-loading.hbs
Normal file
11
ghost/admin/app/templates/settings/integration-loading.hbs
Normal file
@ -0,0 +1,11 @@
|
||||
<section class="gh-canvas">
|
||||
<header class="gh-canvas-header">
|
||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||
{{#link-to "settings.integrations" data-test-link="integrations-back"}}Integrations{{/link-to}}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="gh-content">
|
||||
{{gh-loading-spinner}}
|
||||
</div>
|
||||
</section>
|
155
ghost/admin/app/templates/settings/integration.hbs
Normal file
155
ghost/admin/app/templates/settings/integration.hbs
Normal file
@ -0,0 +1,155 @@
|
||||
<section class="gh-canvas">
|
||||
<form {{action (perform "save") on="submit"}}>
|
||||
<header class="gh-canvas-header">
|
||||
<h2 class="gh-canvas-title" data-test-screen-title>
|
||||
{{#link-to "settings.integrations" data-test-link="integrations-back"}}Integrations{{/link-to}}
|
||||
<span>{{svg-jar "arrow-right"}}</span>
|
||||
{{integration.name}}
|
||||
</h2>
|
||||
<section class="view-actions">
|
||||
{{gh-task-button task=save class="gh-btn gh-btn-blue gh-btn-icon" data-test-button="save"}}
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-row">
|
||||
<figure class="flex items-center" style={{integration-icon-style integration}}>
|
||||
{{#unless integration.iconImage}}
|
||||
{{svg-jar "integration" class="w-100"}}
|
||||
{{/unless}}
|
||||
</figure>
|
||||
<div class="flex flex-column w-100">
|
||||
{{#gh-validation-status-container
|
||||
class="flex flex-column w-100 mr3"
|
||||
errors=integration.errors
|
||||
hasValidated=integration.hasValidated
|
||||
property="name"
|
||||
}}
|
||||
<label for="integration_name">Name</label>
|
||||
{{gh-text-input
|
||||
id="integration_name"
|
||||
class="gh-input"
|
||||
type="text"
|
||||
value=(readonly integration.name)
|
||||
input=(action (mut integration.name) value="target.value")
|
||||
focus-out=(action "validate" "name" target=integration)
|
||||
data-test-input="name"
|
||||
}}
|
||||
{{gh-error-message errors=integration.errors property="name" data-test-error="name"}}
|
||||
{{/gh-validation-status-container}}
|
||||
|
||||
{{#gh-validation-status-container
|
||||
class="flex flex-column w-100 mr3"
|
||||
errors=integration.errors
|
||||
hasValidated=integration.hasValidated
|
||||
property="decription"
|
||||
}}
|
||||
<label for="integration_description">Description</label>
|
||||
{{gh-text-input
|
||||
id="integration_description"
|
||||
class="gh-input"
|
||||
type="text"
|
||||
value=(readonly integration.description)
|
||||
input=(action (mut integration.description) value="target.value")
|
||||
focus-out=(action "validate" "description" target=integration)
|
||||
data-test-input="description"
|
||||
}}
|
||||
{{gh-error-message errors=integration.errors property="description" data-test-error="description"}}
|
||||
{{/gh-validation-status-container}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="gh-setting-header">API Keys</div>
|
||||
<div class="flex flex-row">
|
||||
{{#with integration.contentKey as |contentKey|}}
|
||||
{{#gh-validation-status-container class="flex flex-column w-100 mr3"}}
|
||||
<label for="content_key">Content API Key</label>
|
||||
<div class="relative"
|
||||
onmouseenter={{action (mut showContentKeyActions) true}}
|
||||
onmouseleave={{action (mut showContentKeyActions) false}}
|
||||
>
|
||||
<input id="content_key"
|
||||
class="gh-input"
|
||||
type="text"
|
||||
value={{contentKey.secret}}
|
||||
disabled="true"
|
||||
data-test-input="content_key">
|
||||
|
||||
{{#if showContentKeyActions}}
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" {{action "copyContentKey"}}>Copy</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/gh-validation-status-container}}
|
||||
{{/with}}
|
||||
{{#with integration.adminKey as |adminKey|}}
|
||||
{{#gh-validation-status-container class="flex flex-column w-100 ml3"}}
|
||||
<label for="admin_key">Admin API Key</label>
|
||||
<div class="relative"
|
||||
onmouseenter={{action (mut showAdminKeyActions) true}}
|
||||
onmouseleave={{action (mut showAdminKeyActions) false}}
|
||||
>
|
||||
<input id="admin_key"
|
||||
class="gh-input"
|
||||
type="text"
|
||||
value={{adminKey.secret}}
|
||||
disabled="true"
|
||||
data-test-input="admin_key">
|
||||
|
||||
{{#if showAdminKeyActions}}
|
||||
<div class="absolute top-2 right-2">
|
||||
<button type="button" {{action "copyAdminKey"}}>Copy</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/gh-validation-status-container}}
|
||||
{{/with}}
|
||||
</div>
|
||||
|
||||
<div class="gh-setting-header">Webhooks</div>
|
||||
<div class="mb10 ba br3 b--lightgrey">
|
||||
<table class="ma0">
|
||||
<thead class="bb b--lightgrey">
|
||||
<tr>
|
||||
<th class="pa3 br3 br--bottom br--right bg-lightgrey-l1">Name</th>
|
||||
<th class="pa3 bg-lightgrey-l1">Event</th>
|
||||
<th class="pa3 bg-lightgrey-l1">URL</th>
|
||||
<th class="pa3 br3 br--top br--left bg-lightgrey-l1">Last triggered</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each filteredWebhooks as |webhook|}}
|
||||
<tr>
|
||||
<td class="pa3">{{webhook.name}}</td>
|
||||
<td class="pa3">{{webhook.event}}</td>
|
||||
<td class="pa3">{{webhook.targetUrl}}</td>
|
||||
<td class="pa3">{{webhook.lastTriggeredAtUTC}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="4" class="pa3">
|
||||
No webhooks configured
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
<tfoot class="bt b--lightgrey">
|
||||
<tr>
|
||||
<td colspan="4" class="pa3">
|
||||
{{#link-to "settings.integration.webhooks.new" integration}}+ Add webhook{{/link-to}}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{#if showUnsavedChangesModal}}
|
||||
{{gh-fullscreen-modal "leave-settings"
|
||||
confirm=(action "leaveScreen")
|
||||
close=(action "toggleUnsavedChangesModal")
|
||||
modifier="action wide"}}
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
@ -0,0 +1,5 @@
|
||||
{{gh-fullscreen-modal "new-webhook"
|
||||
model=webhook
|
||||
confirm=(action "save")
|
||||
close=(action "cancel")
|
||||
modifier="action wide"}}
|
@ -100,6 +100,60 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{#if config.enableDeveloperExperiments}}
|
||||
<section class="apps-grid-container pt6">
|
||||
<div class="flex flex-row items-center pb2">
|
||||
<span class="dib flex-grow-1 midgrey">Custom integrations</span>
|
||||
{{#link-to "settings.integrations.new" class="gh-btn gh-btn-green" data-test-button="new-integration"}}
|
||||
<span>Add custom integration</span>
|
||||
{{/link-to}}
|
||||
</div>
|
||||
|
||||
<div class="apps-grid">
|
||||
{{#each integrations as |integration|}}
|
||||
<div class="apps-grid-cell" data-test-custom-integration>
|
||||
{{#link-to "settings.integration" integration data-test-integration=integration.id}}
|
||||
<article class="apps-card-app">
|
||||
<div class="apps-card-left">
|
||||
<figure class="apps-card-app-icon flex items-center" style={{integration-icon-style integration}}>
|
||||
{{#unless integration.iconImage}}
|
||||
{{svg-jar "integration" class="w-100"}}
|
||||
{{/unless}}
|
||||
</figure>
|
||||
<div class="apps-card-meta">
|
||||
<h3 class="apps-card-app-title" data-test-text="name">
|
||||
{{integration.name}}
|
||||
</h3>
|
||||
<p class="apps-card-app-desc" data-test-text="description">
|
||||
{{integration.description}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gh-card-right">
|
||||
<div class="apps-configured">
|
||||
<span>Configure</span>
|
||||
{{svg-jar "arrow-right"}}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{{/link-to}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="flex flex-column justify-center items-center mih40 miw-100" data-test-blank="custom-integrations">
|
||||
{{#if fetchIntegrations.isRunning}}
|
||||
<div class="gh-loading-spinner"></div>
|
||||
{{else}}
|
||||
<p class="ma0 pa0 tc midgrey">
|
||||
Use API keys and webhooks to create custom integrations.<br>
|
||||
No custom integrations.
|
||||
</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</section>
|
||||
{{/if}}
|
||||
</section>
|
||||
|
||||
{{outlet}}
|
5
ghost/admin/app/templates/settings/integrations/new.hbs
Normal file
5
ghost/admin/app/templates/settings/integrations/new.hbs
Normal file
@ -0,0 +1,5 @@
|
||||
{{gh-fullscreen-modal "new-integration"
|
||||
model=integration
|
||||
confirm=(action "save")
|
||||
close=(action "cancel")
|
||||
modifier="action wide"}}
|
19
ghost/admin/app/validators/integration.js
Normal file
19
ghost/admin/app/validators/integration.js
Normal file
@ -0,0 +1,19 @@
|
||||
import BaseValidator from './base';
|
||||
import validator from 'npm:validator';
|
||||
import {isBlank} from '@ember/utils';
|
||||
|
||||
export default BaseValidator.create({
|
||||
properties: ['name'],
|
||||
|
||||
name(model) {
|
||||
if (isBlank(model.name)) {
|
||||
model.errors.add('name', 'Please enter a name');
|
||||
model.hasValidated.pushObject('name');
|
||||
this.invalidate();
|
||||
} else if (!validator.isLength(model.name, 0, 191)) {
|
||||
model.errors.add('name', 'Name is too long, max 191 chars');
|
||||
model.hasValidated.pushObject('name');
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
});
|
43
ghost/admin/app/validators/webhook.js
Normal file
43
ghost/admin/app/validators/webhook.js
Normal file
@ -0,0 +1,43 @@
|
||||
import BaseValidator from './base';
|
||||
import validator from 'npm:validator';
|
||||
import {isBlank} from '@ember/utils';
|
||||
|
||||
export default BaseValidator.create({
|
||||
properties: ['name', 'event', 'targetUrl'],
|
||||
|
||||
name(model) {
|
||||
if (isBlank(model.name)) {
|
||||
model.errors.add('name', 'Please enter a name');
|
||||
model.hasValidated.pushObject('name');
|
||||
this.invalidate();
|
||||
} else if (!validator.isLength(model.name, 0, 191)) {
|
||||
model.errors.add('name', 'Name is too long, max 191 chars');
|
||||
model.hasValidated.pushObject('name');
|
||||
this.invalidate();
|
||||
}
|
||||
},
|
||||
|
||||
event(model) {
|
||||
if (isBlank(model.event)) {
|
||||
model.errors.add('event', 'Please select an event');
|
||||
model.hasValidated.pushObject('event');
|
||||
this.invalidate();
|
||||
}
|
||||
},
|
||||
|
||||
targetUrl(model) {
|
||||
if (isBlank(model.targetUrl)) {
|
||||
model.errors.add('targetUrl', 'Please enter a target URL');
|
||||
} else if (!validator.isURL(model.targetUrl || '', {require_protocol: false})) {
|
||||
model.errors.add('targetUrl', 'Please enter a valid URL');
|
||||
} else if (!validator.isLength(model.targetUrl, 0, 2000)) {
|
||||
model.errors.add('targetUrl', 'Target URL is too long, max 2000 chars');
|
||||
}
|
||||
|
||||
model.hasValidated.pushObject('targetUrl');
|
||||
|
||||
if (model.errors.has('targetUrl')) {
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
});
|
@ -1,5 +1,7 @@
|
||||
import mockApiKeys from './config/api-keys';
|
||||
import mockAuthentication from './config/authentication';
|
||||
import mockConfiguration from './config/configuration';
|
||||
import mockIntegrations from './config/integrations';
|
||||
import mockInvites from './config/invites';
|
||||
import mockPosts from './config/posts';
|
||||
import mockRoles from './config/roles';
|
||||
@ -10,18 +12,22 @@ import mockTags from './config/tags';
|
||||
import mockThemes from './config/themes';
|
||||
import mockUploads from './config/uploads';
|
||||
import mockUsers from './config/users';
|
||||
import mockWebhooks from './config/webhooks';
|
||||
|
||||
// import {versionMismatchResponse} from 'utils';
|
||||
|
||||
export default function () {
|
||||
// this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server
|
||||
this.namespace = '/ghost/api/v0.1'; // make this `api`, for example, if your API is namespaced
|
||||
this.namespace = '/ghost/api/v2/admin'; // make this `api`, for example, if your API is namespaced
|
||||
this.timing = 400; // delay for each request, automatically set to 0 during testing
|
||||
|
||||
// Mock endpoints here to override real API requests during development, eg...
|
||||
// this.put('/posts/:id/', versionMismatchResponse);
|
||||
// mockTags(this);
|
||||
// this.loadFixtures('settings');
|
||||
mockIntegrations(this);
|
||||
mockApiKeys(this);
|
||||
mockWebhooks(this);
|
||||
|
||||
// keep this line, it allows all other API requests to hit the real server
|
||||
this.passthrough();
|
||||
|
8
ghost/admin/mirage/config/api-keys.js
Normal file
8
ghost/admin/mirage/config/api-keys.js
Normal file
@ -0,0 +1,8 @@
|
||||
import {paginatedResponse} from '../utils';
|
||||
|
||||
export default function mockApiKeys(server) {
|
||||
server.get('/api-keys/', paginatedResponse('api-keys'));
|
||||
server.post('/api-keys/');
|
||||
server.put('/api-keys/:id/');
|
||||
server.del('/api-keys/:id/');
|
||||
}
|
64
ghost/admin/mirage/config/integrations.js
Normal file
64
ghost/admin/mirage/config/integrations.js
Normal file
@ -0,0 +1,64 @@
|
||||
import moment from 'moment';
|
||||
import {Response} from 'ember-cli-mirage';
|
||||
import {paginatedResponse} from '../utils';
|
||||
|
||||
export default function mockIntegrations(server) {
|
||||
server.get('/integrations/', paginatedResponse('integrations'));
|
||||
|
||||
server.post('/integrations/', function ({integrations}, {requestBody}) {
|
||||
let body = JSON.parse(requestBody);
|
||||
let [params] = body.integrations;
|
||||
|
||||
if (!params.name) {
|
||||
return new Response(422, {}, {errors: [{
|
||||
errorType: 'ValidationError',
|
||||
message: 'Name is required',
|
||||
property: 'name'
|
||||
}]});
|
||||
}
|
||||
|
||||
if (integrations.findBy({name: params.name}) || params.name.match(/Duplicate/i)) {
|
||||
return new Response(422, {}, {errors: [{
|
||||
errorType: 'ValidationError',
|
||||
message: 'Name has already been used',
|
||||
property: 'name'
|
||||
}]});
|
||||
}
|
||||
|
||||
// allow factory to create defaults
|
||||
if (!params.slug) {
|
||||
delete params.slug;
|
||||
}
|
||||
|
||||
// use factory creation to auto-create api keys
|
||||
return server.create('integration', params);
|
||||
});
|
||||
|
||||
server.put('/integrations/:id/', function (schema, {params}) {
|
||||
let {integrations, apiKeys, webhooks} = schema;
|
||||
let attrs = this.normalizedRequestAttrs();
|
||||
let integration = integrations.find(params.id);
|
||||
let _apiKeys = [];
|
||||
let _webhooks = [];
|
||||
|
||||
// this is required to work around an issue with ember-cli-mirage and
|
||||
// embedded records. The `attrs` object will contain POJOs of the
|
||||
// embedded apiKeys and webhooks but mirage expects schema model
|
||||
// objects for relations so we need to fetch model records and replace
|
||||
// the relationship keys
|
||||
attrs.apiKeys.forEach((apiKey) => {
|
||||
_apiKeys.push(apiKeys.find(apiKey.id));
|
||||
});
|
||||
attrs.webhooks.forEach((webhook) => {
|
||||
_webhooks.push(webhooks.find(webhook.id));
|
||||
});
|
||||
attrs.apiKeys = _apiKeys;
|
||||
attrs.webhooks = _webhooks;
|
||||
|
||||
attrs.updatedAt = moment.utc().format();
|
||||
|
||||
return integration.update(attrs);
|
||||
});
|
||||
|
||||
server.del('/integrations/:id/');
|
||||
}
|
67
ghost/admin/mirage/config/webhooks.js
Normal file
67
ghost/admin/mirage/config/webhooks.js
Normal file
@ -0,0 +1,67 @@
|
||||
import {Response} from 'ember-cli-mirage';
|
||||
import {isEmpty} from '@ember/utils';
|
||||
import {paginatedResponse} from '../utils';
|
||||
|
||||
export default function mockWebhooks(server) {
|
||||
server.get('/webhooks/', paginatedResponse('webhooks'));
|
||||
|
||||
server.post('/webhooks/', function ({webhooks}) {
|
||||
let attrs = this.normalizedRequestAttrs();
|
||||
|
||||
// TODO: should mirage be handling this?
|
||||
attrs.integrationId = attrs.integration;
|
||||
delete attrs.integration;
|
||||
|
||||
let errors = [];
|
||||
|
||||
if (!attrs.name) {
|
||||
errors.push({
|
||||
errorType: 'ValidationError',
|
||||
message: 'Name is required',
|
||||
property: 'name'
|
||||
});
|
||||
}
|
||||
|
||||
if (!attrs.event) {
|
||||
errors.push({
|
||||
errorType: 'ValidationError',
|
||||
message: 'Event is required',
|
||||
property: 'event'
|
||||
});
|
||||
}
|
||||
|
||||
if (!attrs.targetUrl) {
|
||||
errors.push({
|
||||
errorType: 'ValidationError',
|
||||
message: 'Target URL is required',
|
||||
property: 'target_url'
|
||||
});
|
||||
}
|
||||
|
||||
if (attrs.name && (webhooks.findBy({name: attrs.name, integrationId: attrs.integrationId}) || attrs.name.match(/Duplicate/i))) {
|
||||
errors.push({
|
||||
errorType: 'ValidationError',
|
||||
message: 'Name has already been used',
|
||||
property: 'name'
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: check server-side validation
|
||||
if (webhooks.findBy({targetUrl: attrs.targetUrl, event: attrs.event})) {
|
||||
errors.push({
|
||||
errorType: 'ValidationError',
|
||||
message: 'Target URL has already used for this event',
|
||||
property: 'target_url'
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEmpty(errors)) {
|
||||
return new Response(422, {}, {errors});
|
||||
}
|
||||
|
||||
return webhooks.create(attrs);
|
||||
});
|
||||
|
||||
server.put('/webhooks/:id/');
|
||||
server.del('/webhooks/:id/');
|
||||
}
|
20
ghost/admin/mirage/factories/api-key.js
Normal file
20
ghost/admin/mirage/factories/api-key.js
Normal file
@ -0,0 +1,20 @@
|
||||
import moment from 'moment';
|
||||
import {Factory} from 'ember-cli-mirage';
|
||||
|
||||
export default Factory.extend({
|
||||
type: 'content',
|
||||
secret() {
|
||||
if (this.integration) {
|
||||
return `${this.integration.slug}_${this.type}_key-12345`;
|
||||
}
|
||||
return `${this.type}_key-12345`;
|
||||
},
|
||||
lastSeenAt() {
|
||||
return moment.utc().format();
|
||||
},
|
||||
|
||||
createdAt() { return moment.utc().format(); },
|
||||
createdBy: 1,
|
||||
updatedAt() { return moment.utc().format(); },
|
||||
updatedBy: 1
|
||||
});
|
22
ghost/admin/mirage/factories/integration.js
Normal file
22
ghost/admin/mirage/factories/integration.js
Normal file
@ -0,0 +1,22 @@
|
||||
import moment from 'moment';
|
||||
import {Factory} from 'ember-cli-mirage';
|
||||
|
||||
export default Factory.extend({
|
||||
name(i) { return `Integration ${i + 1}`;},
|
||||
slug() { return this.name.toLowerCase().replace(' ', '-'); },
|
||||
description: null,
|
||||
iconImage: null,
|
||||
|
||||
createdAt() { return moment.utc().format(); },
|
||||
createdBy: 1,
|
||||
updatedAt() { return moment.utc().format(); },
|
||||
updatedBy: 1,
|
||||
|
||||
afterCreate(integration, server) {
|
||||
let contentKey = server.create('api-key', {type: 'content', integration});
|
||||
let adminKey = server.create('api-key', {type: 'admin', integration});
|
||||
|
||||
integration.apiKeyIds = [contentKey.id, adminKey.id];
|
||||
integration.save();
|
||||
}
|
||||
});
|
5
ghost/admin/mirage/models/api-key.js
Normal file
5
ghost/admin/mirage/models/api-key.js
Normal file
@ -0,0 +1,5 @@
|
||||
import {Model, belongsTo} from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
integration: belongsTo()
|
||||
});
|
4
ghost/admin/mirage/models/configuration.js
Normal file
4
ghost/admin/mirage/models/configuration.js
Normal file
@ -0,0 +1,4 @@
|
||||
import {Model} from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
});
|
6
ghost/admin/mirage/models/integration.js
Normal file
6
ghost/admin/mirage/models/integration.js
Normal file
@ -0,0 +1,6 @@
|
||||
import {Model, hasMany} from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
apiKeys: hasMany(),
|
||||
webhooks: hasMany()
|
||||
});
|
5
ghost/admin/mirage/models/webhook.js
Normal file
5
ghost/admin/mirage/models/webhook.js
Normal file
@ -0,0 +1,5 @@
|
||||
import {Model, belongsTo} from 'ember-cli-mirage';
|
||||
|
||||
export default Model.extend({
|
||||
integration: belongsTo()
|
||||
});
|
@ -6,4 +6,6 @@ export default function (server) {
|
||||
|
||||
server.createList('subscriber', 125);
|
||||
server.createList('tag', 100);
|
||||
|
||||
server.create('integration', {name: 'Demo'});
|
||||
}
|
||||
|
@ -3,10 +3,22 @@ import {pluralize} from 'ember-cli-mirage/utils/inflector';
|
||||
import {underscore} from '@ember/string';
|
||||
|
||||
export default RestSerializer.extend({
|
||||
keyForCollection(collection) {
|
||||
return underscore(pluralize(collection));
|
||||
},
|
||||
|
||||
keyForAttribute(attr) {
|
||||
return underscore(attr);
|
||||
},
|
||||
|
||||
keyForRelationship(relationship) {
|
||||
return underscore(relationship);
|
||||
},
|
||||
|
||||
keyForEmbeddedRelationship(relationship) {
|
||||
return underscore(relationship);
|
||||
},
|
||||
|
||||
serialize(object, request) {
|
||||
// Ember expects pluralized responses for the post, user, and invite models,
|
||||
// and this shortcut will ensure that those models are pluralized
|
||||
|
13
ghost/admin/mirage/serializers/integration.js
Normal file
13
ghost/admin/mirage/serializers/integration.js
Normal file
@ -0,0 +1,13 @@
|
||||
import BaseSerializer from './application';
|
||||
import {camelize} from '@ember/string';
|
||||
|
||||
export default BaseSerializer.extend({
|
||||
embed: true,
|
||||
|
||||
include(request) {
|
||||
if (!request.queryParams.include) {
|
||||
return;
|
||||
}
|
||||
return request.queryParams.include.split(',').map(camelize);
|
||||
}
|
||||
});
|
1
ghost/admin/public/assets/icons/integration.svg
Normal file
1
ghost/admin/public/assets/icons/integration.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg width="48" height="40" viewBox="0 0 48 40" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path stroke-opacity=".012" stroke="#000" stroke-width="0" d="M0-4h48v48H0z"/><g stroke="#54666D" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M5 1.002h38c2.667 0 4 1.333 4 4v30c0 2.667-1.333 4-4 4H5c-2.667 0-4-1.333-4-4v-30c0-2.667 1.333-4 4-4zM1 11.002h46"/><path d="M6.048 5.502c.333 0 .5.167.5.5 0 .333-.167.5-.5.5-.333 0-.5-.167-.5-.5 0-.138.048-.256.146-.354.098-.098.216-.147.354-.146M10.048 5.502c.333 0 .5.167.5.5 0 .333-.167.5-.5.5-.333 0-.5-.167-.5-.5 0-.138.048-.256.146-.354.098-.098.216-.147.354-.146M14.048 5.502c.333 0 .5.167.5.5 0 .333-.167.5-.5.5-.333 0-.5-.167-.5-.5 0-.138.048-.256.146-.354.098-.098.216-.147.354-.146M26 24.002c0 1.333-.667 2-2 2s-2-.667-2-2 .667-2 2-2 2 .667 2 2z"/><path d="M25.698 16.266l.59 1.936c.24.79.761 1.093 1.566.908l1.962-.456c.856-.192 1.503.092 1.94.853.437.76.357 1.462-.24 2.105l-1.374 1.482c-.56.605-.56 1.21 0 1.816l1.374 1.482c.597.643.677 1.345.24 2.105-.437.761-1.084 1.045-1.94.853l-1.962-.456c-.804-.184-1.326.12-1.566.908l-.59 1.936c-.25.848-.816 1.272-1.7 1.272s-1.45-.424-1.7-1.272l-.59-1.936c-.24-.789-.762-1.092-1.566-.908l-1.962.456c-.856.192-1.503-.092-1.94-.853-.437-.76-.357-1.462.24-2.105l1.374-1.482c.56-.605.56-1.21 0-1.816l-1.374-1.482c-.597-.643-.677-1.345-.24-2.105.437-.761 1.084-1.045 1.94-.853l1.962.456c.805.185 1.327-.118 1.566-.908l.59-1.936c.25-.848.816-1.272 1.7-1.272s1.45.424 1.7 1.272z"/></g></g></svg>
|
After Width: | Height: | Size: 1.5 KiB |
@ -20,44 +20,87 @@ describe('Acceptance: Settings - Integrations', function () {
|
||||
destroyApp(application);
|
||||
});
|
||||
|
||||
it('redirects to signin when not authenticated', async function () {
|
||||
invalidateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
describe('access permissions', function () {
|
||||
beforeEach(function () {
|
||||
server.create('integration', {name: 'Test'});
|
||||
});
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/signin');
|
||||
it('redirects /integrations/ to signin when not authenticated', async function () {
|
||||
invalidateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/signin');
|
||||
});
|
||||
|
||||
it('redirects /integrations/ to team page when authenticated as contributor', async function () {
|
||||
let role = server.create('role', {name: 'Contributor'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
|
||||
});
|
||||
|
||||
it('redirects /integrations/ to team page when authenticated as author', async function () {
|
||||
let role = server.create('role', {name: 'Author'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
|
||||
});
|
||||
|
||||
it('redirects /integrations/ to team page when authenticated as editor', async function () {
|
||||
let role = server.create('role', {name: 'Editor'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations/1');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team');
|
||||
});
|
||||
|
||||
it('redirects /integrations/:id/ to signin when not authenticated', async function () {
|
||||
invalidateSession(application);
|
||||
await visit('/settings/integrations/1');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/signin');
|
||||
});
|
||||
|
||||
it('redirects /integrations/:id/ to team page when authenticated as contributor', async function () {
|
||||
let role = server.create('role', {name: 'Contributor'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations/1');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
|
||||
});
|
||||
|
||||
it('redirects /integrations/:id/ to team page when authenticated as author', async function () {
|
||||
let role = server.create('role', {name: 'Author'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations/1');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
|
||||
});
|
||||
|
||||
it('redirects /integrations/:id/ to team page when authenticated as editor', async function () {
|
||||
let role = server.create('role', {name: 'Editor'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations/1');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to team page when authenticated as contributor', async function () {
|
||||
let role = server.create('role', {name: 'Contributor'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
|
||||
});
|
||||
|
||||
it('redirects to team page when authenticated as author', async function () {
|
||||
let role = server.create('role', {name: 'Author'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team/test-user');
|
||||
});
|
||||
|
||||
it('redirects to team page when authenticated as editor', async function () {
|
||||
let role = server.create('role', {name: 'Editor'});
|
||||
server.create('user', {roles: [role], slug: 'test-user'});
|
||||
|
||||
authenticateSession(application);
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(currentURL(), 'currentURL').to.equal('/team');
|
||||
});
|
||||
|
||||
describe('when logged in', function () {
|
||||
describe('navigation', function () {
|
||||
beforeEach(function () {
|
||||
let role = server.create('role', {name: 'Administrator'});
|
||||
server.create('user', {roles: [role]});
|
||||
@ -117,4 +160,283 @@ describe('Acceptance: Settings - Integrations', function () {
|
||||
expect(currentURL(), 'currentURL').to.equal('/settings/integrations/unsplash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom integrations', function () {
|
||||
beforeEach(function () {
|
||||
server.loadFixtures('configurations');
|
||||
let config = server.schema.configurations.first();
|
||||
config.update({
|
||||
enableDeveloperExperiments: true
|
||||
});
|
||||
|
||||
let role = server.create('role', {name: 'Administrator'});
|
||||
server.create('user', {roles: [role]});
|
||||
|
||||
return authenticateSession(application);
|
||||
});
|
||||
|
||||
it('handles 404', async function () {
|
||||
await visit('/settings/integrations/1');
|
||||
expect(currentPath()).to.equal('error404');
|
||||
});
|
||||
|
||||
it('can add new integration', async function () {
|
||||
// sanity check
|
||||
expect(
|
||||
server.db.integrations.length,
|
||||
'number of integrations in db at start'
|
||||
).to.equal(0);
|
||||
expect(
|
||||
server.db.apiKeys.length,
|
||||
'number of apiKeys in db at start'
|
||||
).to.equal(0);
|
||||
|
||||
// blank slate
|
||||
await visit('/settings/integrations');
|
||||
|
||||
expect(
|
||||
find('[data-test-blank="custom-integrations"]'),
|
||||
'initial blank slate'
|
||||
).to.exist;
|
||||
|
||||
// new integration modal opens/closes
|
||||
await click('[data-test-button="new-integration"]');
|
||||
|
||||
expect(currentURL(), 'url after clicking new').to.equal('/settings/integrations/new');
|
||||
expect(find('[data-test-modal="new-integration"]'), 'modal after clicking new').to.exist;
|
||||
|
||||
await click('[data-test-button="cancel-new-integration"]');
|
||||
|
||||
expect(find('[data-test-modal="new-integration"]'), 'modal after clicking cancel')
|
||||
.to.not.exist;
|
||||
|
||||
expect(
|
||||
find('[data-test-blank="custom-integrations"]'),
|
||||
'blank slate after cancelled creation'
|
||||
).to.exist;
|
||||
|
||||
// new integration validations
|
||||
await click('[data-test-button="new-integration"]');
|
||||
await click('[data-test-button="create-integration"]');
|
||||
|
||||
expect(
|
||||
find('[data-test-error="new-integration-name"]').text(),
|
||||
'name error after create with blank field'
|
||||
).to.have.string('enter a name');
|
||||
|
||||
await fillIn('[data-test-input="new-integration-name"]', 'Duplicate');
|
||||
await click('[data-test-button="create-integration"]');
|
||||
|
||||
expect(
|
||||
find('[data-test-error="new-integration-name"]').text(),
|
||||
'name error after create with duplicate name'
|
||||
).to.have.string('already been used');
|
||||
|
||||
// successful creation
|
||||
await fillIn('[data-test-input="new-integration-name"]', 'Test');
|
||||
|
||||
expect(
|
||||
find('[data-test-error="new-integration-name"]').text().trim(),
|
||||
'name error after typing in field'
|
||||
).to.be.empty;
|
||||
|
||||
await click('[data-test-button="create-integration"]');
|
||||
|
||||
expect(
|
||||
find('[data-test-modal="new-integration"]'),
|
||||
'modal after successful create'
|
||||
).to.not.exist;
|
||||
|
||||
expect(
|
||||
server.db.integrations.length,
|
||||
'number of integrations in db after create'
|
||||
).to.equal(1);
|
||||
// mirage sanity check
|
||||
expect(
|
||||
server.db.apiKeys.length,
|
||||
'number of api keys in db after create'
|
||||
).to.equal(2);
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'url after integration creation'
|
||||
).to.equal('/settings/integrations/1');
|
||||
|
||||
// test navigation back to list then back to new integration
|
||||
await click('[data-test-link="integrations-back"]');
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'url after clicking "Back"'
|
||||
).to.equal('/settings/integrations');
|
||||
|
||||
expect(
|
||||
find('[data-test-blank="custom-integrations"]'),
|
||||
'blank slate after creation'
|
||||
).to.not.exist;
|
||||
|
||||
expect(
|
||||
find('[data-test-custom-integration]').length,
|
||||
'number of custom integrations after creation'
|
||||
).to.equal(1);
|
||||
|
||||
await click(`[data-test-integration="1"]`);
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'url after clicking integration in list'
|
||||
).to.equal('/settings/integrations/1');
|
||||
});
|
||||
|
||||
it('can manage an integration', async function () {
|
||||
server.create('integration');
|
||||
|
||||
await visit('/settings/integrations/1');
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'initial URL'
|
||||
).to.equal('/settings/integrations/1');
|
||||
|
||||
expect(
|
||||
find('[data-test-screen-title]').text(),
|
||||
'screen title'
|
||||
).to.have.string('Integration 1');
|
||||
|
||||
// fields have expected values
|
||||
// TODO: add test for logo
|
||||
|
||||
expect(
|
||||
find('[data-test-input="name"]').val(),
|
||||
'initial name value'
|
||||
).to.equal('Integration 1');
|
||||
|
||||
expect(
|
||||
find('[data-test-input="description"]').val(),
|
||||
'initial description value'
|
||||
).to.equal('');
|
||||
|
||||
expect(
|
||||
find('[data-test-input="content_key"]').val(),
|
||||
'content key input value'
|
||||
).to.equal('integration-1_content_key-12345');
|
||||
|
||||
expect(
|
||||
find('[data-test-input="admin_key"]').val(),
|
||||
'admin key input value'
|
||||
).to.equal('integration-1_admin_key-12345');
|
||||
|
||||
// it can modify integration fields and has validation
|
||||
|
||||
expect(
|
||||
find('[data-test-error="name"]').text().trim(),
|
||||
'initial name error'
|
||||
).to.be.empty;
|
||||
|
||||
await fillIn('[data-test-input="name"]', '');
|
||||
await triggerEvent('[data-test-input="name"]', 'blur');
|
||||
|
||||
expect(
|
||||
find('[data-test-error="name"]').text(),
|
||||
'name validation for blank string'
|
||||
).to.have.string('enter a name');
|
||||
|
||||
await click('[data-test-button="save"]');
|
||||
|
||||
expect(
|
||||
server.schema.integrations.first().name,
|
||||
'db integration name after failed save'
|
||||
).to.equal('Integration 1');
|
||||
|
||||
await fillIn('[data-test-input="name"]', 'Test Integration');
|
||||
await triggerEvent('[data-test-input="name"]', 'blur');
|
||||
|
||||
expect(
|
||||
find('[data-test-error="name"]').text().trim(),
|
||||
'name error after valid entry'
|
||||
).to.be.empty;
|
||||
|
||||
await fillIn('[data-test-input="description"]', 'Description for Test Integration');
|
||||
await triggerEvent('[data-test-input="description"]', 'blur');
|
||||
await click('[data-test-button="save"]');
|
||||
|
||||
// changes are reflected in the integrations list
|
||||
|
||||
await click('[data-test-link="integrations-back"]');
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'url after saving and clicking "back"'
|
||||
).to.equal('/settings/integrations');
|
||||
|
||||
expect(
|
||||
find('[data-test-integration="1"] [data-test-text="name"]').text().trim(),
|
||||
'integration name after save'
|
||||
).to.equal('Test Integration');
|
||||
|
||||
expect(
|
||||
find('[data-test-integration="1"] [data-test-text="description"]').text().trim(),
|
||||
'integration description after save'
|
||||
).to.equal('Description for Test Integration');
|
||||
|
||||
await click('[data-test-integration="1"]');
|
||||
|
||||
// warns of unsaved changes when leaving
|
||||
|
||||
await fillIn('[data-test-input="name"]', 'Unsaved test');
|
||||
await click('[data-test-link="integrations-back"]');
|
||||
|
||||
expect(
|
||||
find('[data-modal="unsaved-settings"]'),
|
||||
'modal shown when navigating with unsaved changes'
|
||||
).to.exist;
|
||||
|
||||
await click('[data-test-stay-button]');
|
||||
|
||||
expect(
|
||||
find('[data-modal="unsaved-settings"]'),
|
||||
'modal is closed after clicking "stay"'
|
||||
).to.not.exist;
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'url after clicking "stay"'
|
||||
).to.equal('/settings/integrations/1');
|
||||
|
||||
await click('[data-test-link="integrations-back"]');
|
||||
await click('[data-test-leave-button]');
|
||||
|
||||
expect(
|
||||
find('[data-modal="unsaved-settings"]'),
|
||||
'modal is closed after clicking "leave"'
|
||||
).to.not.exist;
|
||||
|
||||
expect(
|
||||
currentURL(),
|
||||
'url after clicking "leave"'
|
||||
).to.equal('/settings/integrations');
|
||||
|
||||
expect(
|
||||
find('[data-test-integration="1"] [data-test-text="name"]').text().trim(),
|
||||
'integration name after leaving unsaved changes'
|
||||
).to.equal('Test Integration');
|
||||
});
|
||||
|
||||
// test to ensure the `value=description` passed to `gh-text-input` is `readonly`
|
||||
it('doesn\'t show unsaved changes modal after placing focus on description field', async function () {
|
||||
server.create('integration');
|
||||
|
||||
await visit('/settings/integrations/1');
|
||||
await click('[data-test-input="description"]');
|
||||
await triggerEvent('[data-test-input="description"]', 'blur');
|
||||
await click('[data-test-link="integrations-back"]');
|
||||
|
||||
expect(
|
||||
find('[data-modal="unsaved-settings"]'),
|
||||
'unsaved changes modal is not shown'
|
||||
).to.not.exist;
|
||||
|
||||
expect(currentURL()).to.equal('/settings/integrations');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
17
ghost/admin/tests/unit/models/api-key-test.js
Normal file
17
ghost/admin/tests/unit/models/api-key-test.js
Normal file
@ -0,0 +1,17 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupModelTest} from 'ember-mocha';
|
||||
|
||||
describe('Unit: Model: api-key', function () {
|
||||
setupModelTest('api-key', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: []
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
it('exists', function () {
|
||||
let model = this.subject();
|
||||
// var store = this.store();
|
||||
expect(model).to.be.ok;
|
||||
});
|
||||
});
|
17
ghost/admin/tests/unit/models/integration-test.js
Normal file
17
ghost/admin/tests/unit/models/integration-test.js
Normal file
@ -0,0 +1,17 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupModelTest} from 'ember-mocha';
|
||||
|
||||
describe('Unit: Model: integration', function () {
|
||||
setupModelTest('integration', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: []
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
it('exists', function () {
|
||||
let model = this.subject();
|
||||
// var store = this.store();
|
||||
expect(model).to.be.ok;
|
||||
});
|
||||
});
|
17
ghost/admin/tests/unit/models/webhook-test.js
Normal file
17
ghost/admin/tests/unit/models/webhook-test.js
Normal file
@ -0,0 +1,17 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupModelTest} from 'ember-mocha';
|
||||
|
||||
describe('Unit: Model: webhook', function () {
|
||||
setupModelTest('webhook', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: []
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
it('exists', function () {
|
||||
let model = this.subject();
|
||||
// var store = this.store();
|
||||
expect(model).to.be.ok;
|
||||
});
|
||||
});
|
23
ghost/admin/tests/unit/serializers/api-key-test.js
Normal file
23
ghost/admin/tests/unit/serializers/api-key-test.js
Normal file
@ -0,0 +1,23 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupModelTest} from 'ember-mocha';
|
||||
|
||||
describe('Unit: Serializer: api-key', function () {
|
||||
setupModelTest('api-key', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: [
|
||||
'serializer:api-key',
|
||||
'model:integration',
|
||||
'transform:moment-utc'
|
||||
]
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
it('serializes records', function () {
|
||||
let record = this.subject();
|
||||
|
||||
let serializedRecord = record.serialize();
|
||||
|
||||
expect(serializedRecord).to.be.ok;
|
||||
});
|
||||
});
|
24
ghost/admin/tests/unit/serializers/integration-test.js
Normal file
24
ghost/admin/tests/unit/serializers/integration-test.js
Normal file
@ -0,0 +1,24 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupModelTest} from 'ember-mocha';
|
||||
|
||||
describe('Unit: Serializer: integration', function () {
|
||||
setupModelTest('integration', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: [
|
||||
'serializer:integration',
|
||||
'transform:moment-utc',
|
||||
'model:api-key',
|
||||
'model:webhook'
|
||||
]
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
it('serializes records', function () {
|
||||
let record = this.subject();
|
||||
|
||||
let serializedRecord = record.serialize();
|
||||
|
||||
expect(serializedRecord).to.be.ok;
|
||||
});
|
||||
});
|
23
ghost/admin/tests/unit/serializers/webhook-test.js
Normal file
23
ghost/admin/tests/unit/serializers/webhook-test.js
Normal file
@ -0,0 +1,23 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupModelTest} from 'ember-mocha';
|
||||
|
||||
describe('Unit: Serializer: webhook', function () {
|
||||
setupModelTest('webhook', {
|
||||
// Specify the other units that are required for this test.
|
||||
needs: [
|
||||
'transform:moment-utc',
|
||||
'serializer:webhook',
|
||||
'model:integration'
|
||||
]
|
||||
});
|
||||
|
||||
// Replace this with your real tests.
|
||||
it('serializes records', function () {
|
||||
let record = this.subject();
|
||||
|
||||
let serializedRecord = record.serialize();
|
||||
|
||||
expect(serializedRecord).to.be.ok;
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user