FEATURE: Webhooks.

This commit is contained in:
Erick Guan
2016-06-15 19:49:57 +02:00
committed by Guo Xiang Tan
parent 1f70fc9e11
commit 9ce61b4586
58 changed files with 1582 additions and 38 deletions

View File

@@ -0,0 +1,42 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ['hook-event'],
typeName: Ember.computed.alias('type.name'),
@computed('typeName')
name(typeName) {
return I18n.t(`admin.web_hooks.${typeName}_event.name`);
},
@computed('typeName')
details(typeName) {
return I18n.t(`admin.web_hooks.${typeName}_event.details`);
},
@computed('model.[]', 'typeName')
eventTypeExists(eventTypes, typeName) {
return eventTypes.any(event => event.name === typeName);
},
@computed('eventTypeExists')
enabled: {
get(eventTypeExists) {
return eventTypeExists;
},
set(value, eventTypeExists) {
const type = this.get('type');
const model = this.get('model');
// add an association when not exists
if (value !== eventTypeExists) {
if (value) {
model.addObject(type);
} else {
model.removeObjects(model.filter(eventType => eventType.name === type.name));
}
}
return value;
}
}
});

View File

@@ -0,0 +1,78 @@
import computed from 'ember-addons/ember-computed-decorators';
import { ajax } from 'discourse/lib/ajax';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import { ensureJSON, plainJSON, prettyJSON } from 'discourse/lib/formatter';
export default Ember.Component.extend({
tagName: 'li',
expandDetails: null,
@computed('model.status')
statusColorClasses(status) {
if (!status) return '';
if (status >= 200 && status <= 299) {
return 'text-successful';
} else {
return 'text-danger';
}
},
@computed('model.created_at')
createdAt(createdAt) {
return moment(createdAt).format('YYYY-MM-DD HH:mm:ss');
},
@computed('model.duration')
completion(duration) {
const seconds = Math.floor(duration / 10.0) / 100.0;
return I18n.t('admin.web_hooks.events.completion', { seconds });
},
actions: {
redeliver() {
return bootbox.confirm(I18n.t('admin.web_hooks.events.redeliver_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => {
if (result) {
ajax(`/admin/web_hooks/${this.get('model.web_hook_id')}/events/${this.get('model.id')}/redeliver`, { type: 'POST' }).then(json => {
this.set('model', json.web_hook_event);
}).catch(popupAjaxError);
}
});
},
toggleRequest() {
const expandDetailsKey = 'request';
if (this.get('expandDetails') !== expandDetailsKey) {
let headers = _.extend({
'Request URL': this.get('model.request_url'),
'Request method': 'POST'
}, ensureJSON(this.get('model.headers')));
this.setProperties({
headers: plainJSON(headers),
body: prettyJSON(this.get('model.payload')),
expandDetails: expandDetailsKey,
bodyLabel: I18n.t('admin.web_hooks.events.payload')
});
} else {
this.set('expandDetails', null);
}
},
toggleResponse() {
const expandDetailsKey = 'response';
if (this.get('expandDetails') !== expandDetailsKey) {
this.setProperties({
headers: plainJSON(this.get('model.response_headers')),
body: this.get('model.response_body'),
expandDetails: expandDetailsKey,
bodyLabel: I18n.t('admin.web_hooks.events.body')
});
} else {
this.set('expandDetails', null);
}
}
}
});

View File

@@ -0,0 +1,28 @@
import computed from 'ember-addons/ember-computed-decorators';
import StringBuffer from 'discourse/mixins/string-buffer';
import { iconHTML } from 'discourse/helpers/fa-icon';
export default Ember.Component.extend(StringBuffer, {
classes: ["text-muted", "text-danger", "text-successful"],
icons: ["circle-o", "times-circle", "circle"],
@computed('deliveryStatuses', 'model.last_delivery_status')
status(deliveryStatuses, lastDeliveryStatus) {
return deliveryStatuses.find(s => s.id === lastDeliveryStatus);
},
@computed('status.id', 'icons')
icon(statusId, icons) {
return icons[statusId - 1];
},
@computed('status.id', 'classes')
class(statusId, classes) {
return classes[statusId - 1];
},
renderString(buffer) {
buffer.push(iconHTML(this.get('icon'), { class: this.get('class') }));
buffer.push(I18n.t(`admin.web_hooks.delivery_status.${this.get('status.name')}`));
}
});

View File

@@ -0,0 +1,14 @@
import { ajax } from 'discourse/lib/ajax';
import { popupAjaxError } from 'discourse/lib/ajax-error';
export default Ember.Controller.extend({
actions: {
loadMore() {
this.get('model').loadMore();
},
ping() {
ajax(`/admin/web_hooks/${this.get('model.extras.web_hook_id')}/ping`, {type: 'POST'}).catch(popupAjaxError);
}
}
});

View File

@@ -0,0 +1,98 @@
import { popupAjaxError } from 'discourse/lib/ajax-error';
import { extractDomainFromUrl } from 'discourse/lib/utilities';
import computed from 'ember-addons/ember-computed-decorators';
import InputValidation from 'discourse/models/input-validation';
export default Ember.Controller.extend({
needs: ['adminWebHooks'],
eventTypes: Em.computed.alias('controllers.adminWebHooks.eventTypes'),
defaultEventTypes: Em.computed.alias('controllers.adminWebHooks.defaultEventTypes'),
contentTypes: Em.computed.alias('controllers.adminWebHooks.contentTypes'),
@computed('model.isSaving', 'saved', 'saveButtonDisabled')
savingStatus(isSaving, saved, saveButtonDisabled) {
if (isSaving) {
return I18n.t('saving');
} else if (!saveButtonDisabled && saved) {
return I18n.t('saved');
}
// Use side effect of validation to clear saved text
this.set('saved', false);
return '';
},
@computed('model.isNew')
saveButtonText(isNew) {
return isNew ? I18n.t('admin.web_hooks.create') : I18n.t('admin.web_hooks.save');
},
@computed('model.secret')
secretValidation(secret) {
if (!Ember.isEmpty(secret)) {
if (secret.indexOf(' ') !== -1) {
return InputValidation.create({
failed: true,
reason: I18n.t('admin.web_hooks.secret_invalid')
});
}
if (secret.length < 12) {
return InputValidation.create({
failed: true,
reason: I18n.t('admin.web_hooks.secret_too_short')
});
}
}
},
@computed('model.wildcard_web_hook', 'model.web_hook_event_types.[]')
eventTypeValidation(isWildcard, eventTypes) {
if (!isWildcard && Ember.isEmpty(eventTypes)) {
return InputValidation.create({
failed: true,
reason: I18n.t('admin.web_hooks.event_type_missing')
});
}
},
@computed('model.isSaving', 'secretValidation', 'eventTypeValidation')
saveButtonDisabled(isSaving, secretValidation, eventTypeValidation) {
return isSaving ? false : secretValidation || eventTypeValidation;
},
actions: {
save() {
this.set('saved', false);
const url = extractDomainFromUrl(this.get('model.payload_url'));
const model = this.get('model');
const saveWebHook = () => {
return model.save().then(() => {
this.set('saved', true);
this.get('controllers.adminWebHooks').get('model').addObject(model);
}).catch(popupAjaxError);
};
if (url === 'localhost' || url.match(/192\.168\.\d+\.\d+/) || url.match(/127\.\d+\.\d+\.\d+/) || url === Discourse.BaseUrl) {
return bootbox.confirm(I18n.t('admin.web_hooks.warn_local_payload_url'), I18n.t('no_value'), I18n.t('yes_value'), result => {
if (result) {
return saveWebHook();
}
});
}
return saveWebHook();
},
destroy() {
return bootbox.confirm(I18n.t('admin.web_hooks.delete_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => {
if (result) {
const model = this.get('model');
model.destroyRecord().then(() => {
this.get('controllers.adminWebHooks').get('model').removeObject(model);
this.transitionToRoute('adminWebHooks');
}).catch(popupAjaxError);
}
});
}
}
});

View File

@@ -0,0 +1,19 @@
import { popupAjaxError } from 'discourse/lib/ajax-error';
export default Ember.Controller.extend({
actions: {
destroy(webhook) {
return bootbox.confirm(I18n.t('admin.web_hooks.delete_confirm'), I18n.t('no_value'), I18n.t('yes_value'), result => {
if (result) {
webhook.destroyRecord().then(() => {
this.get('model').removeObject(webhook);
}).catch(popupAjaxError);
}
});
},
loadMore() {
this.get('model').loadMore();
}
}
});

View File

@@ -0,0 +1,85 @@
import RestModel from 'discourse/models/rest';
import Category from 'discourse/models/category';
import Group from 'discourse/models/group';
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
export default RestModel.extend({
content_type: 1, // json
last_delivery_status: 1, // inactive
wildcard_web_hook: false,
verify_certificate: true,
active: false,
web_hook_event_types: null,
categoriesFilter: null,
groupsFilterInName: null,
@computed('wildcard_web_hook')
webHookType: {
get(wildcard) {
return wildcard ? 'wildcard' : 'individual';
},
set(value) {
this.set('wildcard_web_hook', value === 'wildcard');
}
},
@observes('category_ids')
updateCategoriesFilter() {
this.set('categoriesFilter', Category.findByIds(this.get('category_ids')));
},
@observes('group_ids')
updateGroupsFilter() {
const groupIds = this.get('group_ids');
this.set('groupsFilterInName', Discourse.Site.currentProp('groups').reduce((groupNames, g) => {
if (groupIds.includes(g.id)) { groupNames.push(g.name); }
return groupNames;
}, []));
},
groupFinder(term) {
return Group.findAll({search: term, ignore_automatic: false});
},
@computed('wildcard_web_hook', 'web_hook_event_types.[]')
description(isWildcardWebHook, types) {
let desc = '';
types.forEach(type => {
const name = `${type.name.toLowerCase()}_event`;
desc += (desc !== '' ? `, ${name}` : name);
});
return (isWildcardWebHook ? '*' : desc);
},
createProperties() {
const types = this.get('web_hook_event_types');
const categories = this.get('categoriesFilter');
// Hack as {{group-selector}} accepts a comma-separated string as data source, but
// we use an array to populate the datasource above.
const groupsFilter = this.get('groupsFilterInName');
const groupNames = typeof groupsFilter === 'string' ? groupsFilter.split(',') : groupsFilter;
return {
payload_url: this.get('payload_url'),
content_type: this.get('content_type'),
secret: this.get('secret'),
wildcard_web_hook: this.get('wildcard_web_hook'),
verify_certificate: this.get('verify_certificate'),
active: this.get('active'),
web_hook_event_type_ids: Ember.isEmpty(types) ? [null] : types.map(type => type.id),
category_ids: Ember.isEmpty(categories) ? [null] : categories.map(c => c.id),
group_ids: Ember.isEmpty(groupNames) || Ember.isEmpty(groupNames[0]) ? [null] : Discourse.Site.currentProp('groups')
.reduce((groupIds, g) => {
if (groupNames.includes(g.name)) { groupIds.push(g.id); }
return groupIds;
}, [])
};
},
updateProperties() {
return this.createProperties();
}
});

View File

@@ -36,6 +36,10 @@ export default {
});
});
this.route('api');
this.resource('adminWebHooks', { path: '/web_hooks' }, function() {
this.route('show', { path: '/:web_hook_id' });
this.route('showEvents', { path: '/:web_hook_id/events' });
});
this.resource('admin.backups', { path: '/backups' }, function() {
this.route('logs');

View File

@@ -0,0 +1,13 @@
export default Discourse.Route.extend({
model(params) {
return this.store.findAll('web-hook-event', Ember.get(params, 'web_hook_id'));
},
setupController(controller, model) {
controller.set('model', model);
},
renderTemplate() {
this.render('admin/templates/web-hooks-show-events', { into: 'admin' });
}
});

View File

@@ -0,0 +1,26 @@
export default Discourse.Route.extend({
serialize(model) {
return { web_hook_id: model.get('id') || 'new' };
},
model(params) {
if (params.web_hook_id === 'new') {
return this.store.createRecord('web-hook');
}
return this.store.find('web-hook', Ember.get(params, 'web_hook_id'));
},
setupController(controller, model) {
if (model.get('isNew') || Ember.isEmpty(model.get('web_hook_event_types'))) {
model.set('web_hook_event_types', controller.get('defaultEventTypes'));
}
model.set('category_ids', model.get('category_ids'));
model.set('group_ids', model.get('group_ids'));
controller.setProperties({ model, saved: false });
},
renderTemplate() {
this.render('admin/templates/web-hooks-show', { into: 'admin' });
}
});

View File

@@ -0,0 +1,15 @@
export default Ember.Route.extend({
model() {
return this.store.findAll('web-hook');
},
setupController(controller, model) {
controller.setProperties({
model,
eventTypes: model.extras.event_types,
defaultEventTypes: model.extras.default_event_types,
contentTypes: model.extras.content_types,
deliveryStatuses: model.extras.delivery_statuses
});
}
});

View File

@@ -20,6 +20,7 @@
{{#if currentUser.admin}}
{{nav-item route='adminCustomize' label='admin.customize.title'}}
{{nav-item route='admin.api' label='admin.api.title'}}
{{nav-item route='adminWebHooks' label='admin.web_hooks.title'}}
{{nav-item route='admin.backups' label='admin.backups.title'}}
{{/if}}
{{nav-item route='adminPlugins' label='admin.plugins.title'}}

View File

@@ -0,0 +1,3 @@
{{input id=typeName type="checkbox" name="event-choice" checked=enabled}}
<label for={{typeName}}>{{name}}</label>
<p>{{details}}</p>

View File

@@ -0,0 +1,19 @@
<div class="col first">
<span class="{{statusColorClasses}}">{{model.status}}</span>
</div>
<div class="col event-id">{{model.id}}</div>
<div class="col timestamp">{{createdAt}}</div>
<div class="col completion">{{completion}}</div>
<div class="col actions">
{{d-button icon='ellipsis-v' action='toggleRequest' label='admin.web_hooks.events.request'}}
{{d-button icon='ellipsis-v' action='toggleResponse' label='admin.web_hooks.events.response'}}
{{d-button icon='refresh' action='redeliver' label='admin.web_hooks.events.redeliver'}}
</div>
{{#if expandDetails}}
<div class="details">
<h3>{{i18n 'admin.web_hooks.events.headers'}}</h3>
<pre><code>{{headers}}</code></pre>
<h3>{{bodyLabel}}</h3>
<pre><code>{{body}}</code></pre>
</div>
{{/if}}

View File

@@ -1,3 +1,3 @@
{{category-group categories=selectedCategories blacklist=selectedCategories}}
{{category-selector categories=selectedCategories blacklist=selectedCategories}}
<div class='desc'>{{{unbound setting.description}}}</div>
{{setting-validation-message message=validationMessage}}

View File

@@ -0,0 +1,26 @@
<div class="web-hook-direction">
{{#link-to 'adminWebHooks' tagName='button' classNames='btn'}}
{{fa-icon 'list'}} {{i18n 'admin.web_hooks.events.go_list'}}
{{/link-to}}
{{d-button icon="send" label="admin.web_hooks.events.ping" action="ping"}}
{{#link-to 'adminWebHooks.show' model.extras.web_hook_id tagName='button' classNames='btn'}}
{{fa-icon 'edit'}} {{i18n 'admin.web_hooks.events.go_details'}}
{{/link-to}}
</div>
<div class='web-hook-events-listing'>
{{#if model}}
{{#load-more selector=".web-hook-events li" action="loadMore"}}
<div class='web-hook-events content-list'>
<ul>
{{#each model as |webHookEvent|}}
{{admin-web-hook-event model=webHookEvent}}
{{/each}}
</ul>
</div>
{{conditional-loading-spinner condition=model.loadingMore}}
{{/load-more}}
{{else}}
<p>{{i18n 'admin.web_hooks.events.none'}}</p>
{{/if}}
</div>

View File

@@ -0,0 +1,86 @@
{{#link-to 'adminWebHooks' class="go-back"}}
{{fa-icon 'arrow-left'}}
{{i18n 'admin.web_hooks.go_back'}}
{{/link-to}}
<div class='web-hook-container'>
<p>{{i18n 'admin.web_hooks.detailed_instruction'}}</p>
<form class='web-hook form-horizontal'>
<div>
<label for='payload-url'>{{i18n 'admin.web_hooks.payload_url'}}</label>
{{text-field name="payload-url" value=model.payload_url placeholderKey="admin.web_hooks.payload_url_placeholder"}}
{{input-tip validation=urlValidation}}
</div>
<div>
<label for='content-type'>{{i18n 'admin.web_hooks.content_type'}}</label>
{{combo-box content=contentTypes
name="content-type"
nameProperty="name"
valueAttribute="id"
value=model.content_type}}
</div>
<div>
<label for='secret'>{{i18n 'admin.web_hooks.secret'}}</label>
{{text-field name="secret" value=model.secret placeholderKey="admin.web_hooks.secret_placeholder"}}
{{input-tip validation=secretValidation}}
</div>
<div class="cbox10">
<label>{{i18n 'admin.web_hooks.event_chooser'}}</label>
<div>
{{radio-button class="subscription-choice" name="subscription-choice" value="individual" selection=model.webHookType}}
{{i18n 'admin.web_hooks.individual_event'}}
{{input-tip validation=eventTypeValidation}}
</div>
{{#unless model.wildcard_web_hook}}
<div class="event-selector">
{{#each eventTypes as |type|}}
{{admin-web-hook-event-chooser type=type model=model.web_hook_event_types}}
{{/each}}
</div>
{{/unless}}
<div>
{{radio-button class="subscription-choice" name="subscription-choice" value="wildcard" selection=model.webHookType}}
{{i18n 'admin.web_hooks.wildcard_event'}}
</div>
</div>
<div class='filters'>
<div>
<label>{{fa-icon 'circle' class='tracking'}}{{i18n 'admin.web_hooks.categories_filter'}}</label>
{{category-selector categories=model.categoriesFilter blacklist=model.categoriesFilter}}
<div class="instructions">{{i18n 'admin.web_hooks.categories_filter_instructions'}}</div>
</div>
<div>
<label>{{fa-icon 'circle' class='tracking'}}{{i18n 'admin.web_hooks.groups_filter'}}</label>
{{group-selector groupNames=model.groupsFilterInName groupFinder=model.groupFinder}}
<div class="instructions">{{i18n 'admin.web_hooks.groups_filter_instructions'}}</div>
</div>
</div>
<div>
{{input type="checkbox" name="verify_certificate" checked=model.verify_certificate}} {{i18n 'admin.web_hooks.verify_certificate'}}
</div>
<div>
<div>
{{input type="checkbox" name="active" checked=model.active}} {{i18n 'admin.web_hooks.active'}}
</div>
{{#if model.active}}
<div class="instructions">{{i18n 'admin.web_hooks.active_notice'}}</div>
{{/if}}
</div>
</form>
<div class='controls'>
<button class='btn btn-default' {{action 'save'}} disabled={{saveButtonDisabled}}>{{saveButtonText}}</button>
{{#unless model.isNew}}
{{d-button class="btn-danger" label="admin.web_hooks.destroy" action="destroy"}}
{{#link-to 'adminWebHooks.showEvents' model.id class="btn"}}
{{i18n 'admin.web_hooks.events.go_events'}}
{{/link-to}}
{{/unless}}
<span class='saving'>{{savingStatus}}</span>
</div>
</div>

View File

@@ -0,0 +1,39 @@
<div class='pull-right'>
{{#link-to 'adminWebHooks.show' 'new' tagName='button' classNames='btn'}}
{{fa-icon 'plus'}} {{i18n 'admin.web_hooks.new'}}
{{/link-to}}
</div>
<div class='clearfix'></div>
<div class='web-hooks-listing'>
{{#if model}}
<p>{{i18n 'admin.web_hooks.instruction'}}</p>
{{#load-more selector=".web-hooks tr" action="loadMore"}}
<table class='web-hooks'>
<thead>
<tr>
<th>{{i18n 'admin.web_hooks.delivery_status.title'}}</th>
<th>{{i18n 'admin.web_hooks.payload_url'}}</th>
<th>{{i18n 'admin.web_hooks.description'}}</th>
<th>{{i18n 'admin.web_hooks.controls'}}</th>
</tr>
</thead>
<tbody>
{{#each model as |webHook|}}
<tr>
<td>{{#link-to 'adminWebHooks.showEvents' webHook.id}}{{admin-web-hook-status deliveryStatuses=deliveryStatuses model=webHook}}{{/link-to}}</td>
<td>{{#link-to 'adminWebHooks.show' webHook}}{{webHook.payload_url}}{{/link-to}}</td>
<td class='description'>{{webHook.description}}</td>
<td class='controls'>
{{#link-to 'adminWebHooks.show' webHook tagName='button' classNames='btn btn-default no-text'}}{{fa-icon 'edit'}}{{/link-to}}
{{d-button class="destroy btn-danger" action='destroy' actionParam=webHook icon="remove"}}
</td>
</tr>
{{/each}}
</tbody>
</table>
{{conditional-loading-spinner condition=model.loadingMore}}
{{/load-more}}
{{else}}
<p>{{i18n 'admin.web_hooks.none'}}</p>
{{/if}}
</div>

View File

@@ -1,8 +1,7 @@
import { ajax } from 'discourse/lib/ajax';
import { hashString } from 'discourse/lib/hash';
const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host'];
const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host', 'web-hook', 'web-hook-event'];
export function Result(payload, responseJson) {
this.payload = payload;
@@ -57,8 +56,8 @@ export default Ember.Object.extend({
return this.appendQueryParams(path, findArgs);
},
findAll(store, type) {
return ajax(this.pathFor(store, type)).catch(rethrow);
findAll(store, type, findArgs) {
return ajax(this.pathFor(store, type, findArgs)).catch(rethrow);
},

View File

@@ -1,37 +1,37 @@
import { categoryBadgeHTML } from 'discourse/helpers/category-link';
import Category from 'discourse/models/category';
import { on } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
_initializeAutocomplete: function() {
@on('didInsertElement')
_initializeAutocomplete() {
const self = this,
template = this.container.lookup('template:category-group-autocomplete.raw'),
template = this.container.lookup('template:category-selector-autocomplete.raw'),
regexp = new RegExp(`href=['\"]${Discourse.getURL('/c/')}([^'\"]+)`);
this.$('input').autocomplete({
items: this.get('categories'),
single: false,
allowAny: false,
dataSource(term){
return Category.list().filter(function(category){
const regex = new RegExp(term, "i");
return category.get("name").match(regex) &&
dataSource(term) {
return Category.list().filter(category => {
const regex = new RegExp(term, 'i');
return category.get('name').match(regex) &&
!_.contains(self.get('blacklist') || [], category) &&
!_.contains(self.get('categories'), category) ;
});
},
onChangeItems(items) {
const categories = _.map(items, function(link) {
const categories = _.map(items, link => {
const slug = link.match(regexp)[1];
return Category.findSingleBySlug(slug);
});
Em.run.next(() => self.set("categories", categories));
Em.run.next(() => self.set('categories', categories));
},
template,
transformComplete(category) {
return categoryBadgeHTML(category, {allowUncategorized: true});
}
});
}.on('didInsertElement')
}
});

View File

@@ -1,15 +1,20 @@
export default Ember.Component.extend({
placeholder: function(){
return I18n.t(this.get("placeholderKey"));
}.property("placeholderKey"),
import { on, default as computed } from 'ember-addons/ember-computed-decorators';
_initializeAutocomplete: function() {
export default Ember.Component.extend({
@computed('placeholderKey')
placeholder(placeholderKey) {
return placeholderKey ? I18n.t(placeholderKey) : '';
},
@on('didInsertElement')
_initializeAutocomplete() {
var self = this;
var selectedGroups;
var template = this.container.lookup('template:group-selector-autocomplete.raw');
self.$('input').autocomplete({
allowAny: false,
items: this.get('groupNames'),
onChangeItems: function(items){
selectedGroups = items;
self.set("groupNames", items.join(","));
@@ -31,5 +36,5 @@ export default Ember.Component.extend({
},
template: template
});
}.on('didInsertElement')
}
});

View File

@@ -265,3 +265,20 @@ export function number(val) {
}
return val.toString();
}
export function ensureJSON(json) {
return typeof json === 'string' ? JSON.parse(json) : json;
}
export function plainJSON(val) {
let json = ensureJSON(val);
let headers = '';
Object.keys(json).forEach(k => {
headers += `${k}: ${json[k]}\n`;
});
return headers;
}
export function prettyJSON(json) {
return JSON.stringify(ensureJSON(json), null, 2);
}

View File

@@ -71,10 +71,19 @@ export function userUrl(username) {
export function emailValid(email) {
// see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
var re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/;
const re = /^[a-zA-Z0-9!#$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$/;
return re.test(email);
}
export function extractDomainFromUrl(url) {
if (url.indexOf("://") > -1) {
url = url.split('/')[2];
} else {
url = url.split('/')[0];
}
return url.split(':')[0];
}
export function selectedText() {
var html = '';

View File

@@ -156,7 +156,7 @@ const Group = Discourse.Model.extend({
data: { notification_level },
type: "POST"
});
},
}
});
Group.reopenClass({

View File

@@ -49,9 +49,9 @@ export default Ember.Object.extend({
this._plurals[thing] = plural;
},
findAll(type) {
findAll(type, findArgs) {
const self = this;
return this.adapterFor(type).findAll(this, type).then(function(result) {
return this.adapterFor(type).findAll(this, type, findArgs).then(function(result) {
return self._resultSet(type, result);
});
},

View File

@@ -1 +0,0 @@
<input class='category-group' type='text'>

View File

@@ -0,0 +1 @@
<input class='category-selector' type='text' name='categories'>

View File

@@ -1 +1 @@
<input class='ember-text-field group-names' type="text" placeholder={{placeholder}} name="groups">
<input class='group-selector' placeholder={{placeholder}} type='text' name='groups'>

View File

@@ -250,7 +250,7 @@
<label class="control-label">{{i18n 'user.categories_settings'}}</label>
<div class="controls category-controls">
<label><span class="icon fa fa-exclamation-circle watching"></span> {{i18n 'user.watched_categories'}}</label>
{{category-group categories=model.watchedCategories blacklist=selectedCategories}}
{{category-selector categories=model.watchedCategories blacklist=selectedCategories}}
</div>
<div class="instructions">{{i18n 'user.watched_categories_instructions'}}</div>
<div class="controls category-controls">
@@ -259,17 +259,17 @@
<div class="instructions"></div>
<div class="controls category-controls">
<label><span class="icon fa fa-circle tracking"></span> {{i18n 'user.tracked_categories'}}</label>
{{category-group categories=model.trackedCategories blacklist=selectedCategories}}
{{category-selector categories=model.trackedCategories blacklist=selectedCategories}}
</div>
<div class="instructions">{{i18n 'user.tracked_categories_instructions'}}</div>
<div class="controls category-controls">
<label><span class="icon fa fa-dot-circle-o watching-first-post"></span> {{i18n 'user.watched_first_post_categories'}}</label>
{{category-group categories=model.watchedFirstPostCategories}}
{{category-selector categories=model.watchedFirstPostCategories}}
</div>
<div class="instructions">{{i18n 'user.watched_first_post_categories_instructions'}}</div>
<div class="controls category-controls">
<label><span class="icon fa fa-times-circle muted"></span> {{i18n 'user.muted_categories'}}</label>
{{category-group categories=model.mutedCategories blacklist=selectedCategories}}
{{category-selector categories=model.mutedCategories blacklist=selectedCategories}}
</div>
<div class="instructions">{{i18n 'user.muted_categories_instructions'}}</div>
<div class="controls category-controls">