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:
Kevin Ansfield 2018-10-18 00:18:29 +01:00 committed by GitHub
parent faf489fcd9
commit 5047b9f3d7
52 changed files with 1613 additions and 43 deletions

View File

@ -1,6 +1,7 @@
import BaseAdapter from 'ghost-admin/adapters/base'; import BaseAdapter from 'ghost-admin/adapters/base';
import {get} from '@ember/object'; import {get} from '@ember/object';
import {isNone} from '@ember/utils'; import {isNone} from '@ember/utils';
import {underscore} from '@ember/string';
// EmbeddedRelationAdapter will augment the query object in calls made to // EmbeddedRelationAdapter will augment the query object in calls made to
// DS.Store#findRecord, findAll, query, and queryRecord with the correct "includes" // 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); let url = this.buildURL(modelName, id, snapshot, requestType, query);
if (includes.length) { if (includes.length) {
url += `?include=${includes.join(',')}`; url += `?include=${includes.map(underscore).join(',')}`;
} }
return url; return url;
@ -92,7 +93,7 @@ export default BaseAdapter.extend({
if (typeof options === 'string' || typeof options === 'number') { if (typeof options === 'string' || typeof options === 'number') {
query = {}; query = {};
query.id = options; query.id = options;
query.include = toInclude.join(','); query.include = toInclude.map(underscore).join(',');
} else if (typeof options === 'object' || isNone(options)) { } else if (typeof options === 'object' || isNone(options)) {
// If this is a find all (no existing query object) build one and attach // If this is a find all (no existing query object) build one and attach
// the includes. // the includes.

View 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()
});

View 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;
}
}
})
});

View 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;
}
});

View File

@ -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);
});
}
}
});

View File

@ -1,7 +1,31 @@
/* eslint-disable ghost/ember/alias-model-in-controller */ /* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller'; import Controller from '@ember/controller';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default Controller.extend({ 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');
})
}); });

View 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');
}
}
});

View 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);

View File

@ -1,4 +1,5 @@
import DS from 'ember-data'; import DS from 'ember-data';
import IntegrationValidator from 'ghost-admin/validators/integration';
import InviteUserValidator from 'ghost-admin/validators/invite-user'; import InviteUserValidator from 'ghost-admin/validators/invite-user';
import Mixin from '@ember/object/mixin'; import Mixin from '@ember/object/mixin';
import Model from 'ember-data/model'; 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 SubscriberValidator from 'ghost-admin/validators/subscriber';
import TagSettingsValidator from 'ghost-admin/validators/tag-settings'; import TagSettingsValidator from 'ghost-admin/validators/tag-settings';
import UserValidator from 'ghost-admin/validators/user'; import UserValidator from 'ghost-admin/validators/user';
import WebhookValidator from 'ghost-admin/validators/webhook';
import {A as emberA, isArray as isEmberArray} from '@ember/array'; import {A as emberA, isArray as isEmberArray} from '@ember/array';
const {Errors} = DS; const {Errors} = DS;
@ -42,7 +44,9 @@ export default Mixin.create({
slackIntegration: SlackIntegrationValidator, slackIntegration: SlackIntegrationValidator,
subscriber: SubscriberValidator, subscriber: SubscriberValidator,
tag: TagSettingsValidator, tag: TagSettingsValidator,
user: UserValidator user: UserValidator,
integration: IntegrationValidator,
webhook: WebhookValidator
}, },
// This adds the Errors object to the validation engine, and shouldn't affect // This adds the Errors object to the validation engine, and shouldn't affect

View 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')
});

View 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');
})
});

View 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')
});

View File

@ -52,8 +52,12 @@ Router.map(function () {
this.route('settings.design', {path: '/settings/design'}, function () { this.route('settings.design', {path: '/settings/design'}, function () {
this.route('uploadtheme'); this.route('uploadtheme');
}); });
this.route('settings.integrations', {path: '/settings/integrations'}, function () {
this.route('settings.integrations', {path: '/settings/integrations'}); 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.slack', {path: '/settings/integrations/slack'});
this.route('settings.integrations.amp', {path: '/settings/integrations/amp'}); this.route('settings.integrations.amp', {path: '/settings/integrations/amp'});
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'}); this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});

View 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;
}
}
}
});

View 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();
}
});

View File

@ -14,5 +14,11 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
return this.get('session.user') return this.get('session.user')
.then(this.transitionAuthor()) .then(this.transitionAuthor())
.then(this.transitionEditor()); .then(this.transitionEditor());
},
setupController(controller) {
// kick off the background fetch of integrations so that we can
// show the screen immediately
controller.fetchIntegrations.perform();
} }
}); });

View 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();
}
});

View 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'}
}
});

View 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'}
}
});

View 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'}
}
});

View File

@ -148,6 +148,7 @@ select {
.gh-input.error, .gh-input.error,
.error .gh-input, .error .gh-input,
.error .gh-select select,
.error .ember-power-select-multiple-trigger, .error .ember-power-select-multiple-trigger,
.gh-select.error, .gh-select.error,
select.error { select.error {

View File

@ -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> <h1>Are you sure you want to leave this page?</h1>
</header> </header>
<a class="close" href="" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a> <a class="close" href="" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>

View File

@ -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>

View 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>

View 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>

View 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}}

View File

@ -0,0 +1,5 @@
{{gh-fullscreen-modal "new-webhook"
model=webhook
confirm=(action "save")
close=(action "cancel")
modifier="action wide"}}

View File

@ -100,6 +100,60 @@
</div> </div>
</div> </div>
</section> </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> </section>
{{outlet}} {{outlet}}

View File

@ -0,0 +1,5 @@
{{gh-fullscreen-modal "new-integration"
model=integration
confirm=(action "save")
close=(action "cancel")
modifier="action wide"}}

View 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();
}
}
});

View 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();
}
}
});

View File

@ -1,5 +1,7 @@
import mockApiKeys from './config/api-keys';
import mockAuthentication from './config/authentication'; import mockAuthentication from './config/authentication';
import mockConfiguration from './config/configuration'; import mockConfiguration from './config/configuration';
import mockIntegrations from './config/integrations';
import mockInvites from './config/invites'; import mockInvites from './config/invites';
import mockPosts from './config/posts'; import mockPosts from './config/posts';
import mockRoles from './config/roles'; import mockRoles from './config/roles';
@ -10,18 +12,22 @@ import mockTags from './config/tags';
import mockThemes from './config/themes'; import mockThemes from './config/themes';
import mockUploads from './config/uploads'; import mockUploads from './config/uploads';
import mockUsers from './config/users'; import mockUsers from './config/users';
import mockWebhooks from './config/webhooks';
// import {versionMismatchResponse} from 'utils'; // import {versionMismatchResponse} from 'utils';
export default function () { export default function () {
// this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server // 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 this.timing = 400; // delay for each request, automatically set to 0 during testing
// Mock endpoints here to override real API requests during development, eg... // Mock endpoints here to override real API requests during development, eg...
// this.put('/posts/:id/', versionMismatchResponse); // this.put('/posts/:id/', versionMismatchResponse);
// mockTags(this); // mockTags(this);
// this.loadFixtures('settings'); // this.loadFixtures('settings');
mockIntegrations(this);
mockApiKeys(this);
mockWebhooks(this);
// keep this line, it allows all other API requests to hit the real server // keep this line, it allows all other API requests to hit the real server
this.passthrough(); this.passthrough();

View 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/');
}

View 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/');
}

View 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/');
}

View 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
});

View 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();
}
});

View File

@ -0,0 +1,5 @@
import {Model, belongsTo} from 'ember-cli-mirage';
export default Model.extend({
integration: belongsTo()
});

View File

@ -0,0 +1,4 @@
import {Model} from 'ember-cli-mirage';
export default Model.extend({
});

View File

@ -0,0 +1,6 @@
import {Model, hasMany} from 'ember-cli-mirage';
export default Model.extend({
apiKeys: hasMany(),
webhooks: hasMany()
});

View File

@ -0,0 +1,5 @@
import {Model, belongsTo} from 'ember-cli-mirage';
export default Model.extend({
integration: belongsTo()
});

View File

@ -6,4 +6,6 @@ export default function (server) {
server.createList('subscriber', 125); server.createList('subscriber', 125);
server.createList('tag', 100); server.createList('tag', 100);
server.create('integration', {name: 'Demo'});
} }

View File

@ -3,10 +3,22 @@ import {pluralize} from 'ember-cli-mirage/utils/inflector';
import {underscore} from '@ember/string'; import {underscore} from '@ember/string';
export default RestSerializer.extend({ export default RestSerializer.extend({
keyForCollection(collection) {
return underscore(pluralize(collection));
},
keyForAttribute(attr) { keyForAttribute(attr) {
return underscore(attr); return underscore(attr);
}, },
keyForRelationship(relationship) {
return underscore(relationship);
},
keyForEmbeddedRelationship(relationship) {
return underscore(relationship);
},
serialize(object, request) { serialize(object, request) {
// Ember expects pluralized responses for the post, user, and invite models, // Ember expects pluralized responses for the post, user, and invite models,
// and this shortcut will ensure that those models are pluralized // and this shortcut will ensure that those models are pluralized

View 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);
}
});

View 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

View File

@ -20,44 +20,87 @@ describe('Acceptance: Settings - Integrations', function () {
destroyApp(application); destroyApp(application);
}); });
it('redirects to signin when not authenticated', async function () { describe('access permissions', function () {
invalidateSession(application); beforeEach(function () {
await visit('/settings/integrations'); 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 () { describe('navigation', 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 () {
beforeEach(function () { beforeEach(function () {
let role = server.create('role', {name: 'Administrator'}); let role = server.create('role', {name: 'Administrator'});
server.create('user', {roles: [role]}); server.create('user', {roles: [role]});
@ -117,4 +160,283 @@ describe('Acceptance: Settings - Integrations', function () {
expect(currentURL(), 'currentURL').to.equal('/settings/integrations/unsplash'); 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');
});
});
}); });

View 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;
});
});

View 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;
});
});

View 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;
});
});

View 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;
});
});

View 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;
});
});

View 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;
});
});