DEV: Admin webhooks interface issues (#19360)

1. The events table had broken styling, making each row overflow
2. It had confusing routes: `/:id` for "edit" and `/:id/events` for "show" (now it's `/:id/edit` and `/:id` respectively)
3. There previously was an unused backend action (`#edit`) - now it is used (and `web_hooks/:id/events` route has been removed)
4. There was outdated/misplaced/duplicated CSS
5. And more
This commit is contained in:
Jarek Radosz 2022-12-13 01:53:08 +01:00 committed by GitHub
parent 4001e6f174
commit f9bdda84ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 611 additions and 443 deletions

View File

@ -1,16 +1,17 @@
<div class="web-hook-direction">
<LinkTo @route="adminWebHooks" class="btn">
{{d-icon "list"}} {{i18n "admin.web_hooks.events.go_list"}}
</LinkTo>
<DButton @icon="paper-plane" @label="admin.web_hooks.events.ping" @action={{action "ping"}} @disabled={{this.pingDisabled}} />
<LinkTo @route="adminWebHooks.show" @model={{this.model.extras.web_hook_id}} class="btn">
{{d-icon "far-edit"}} {{i18n "admin.web_hooks.events.go_details"}}
</LinkTo>
</div>
<div class="web-hook-events-listing"
{{did-insert this.subscribe}}
{{will-destroy this.unsubscribe}}
>
<DButton
@icon="paper-plane"
@label="admin.web_hooks.events.ping"
@action={{this.ping}}
@disabled={{not this.pingEnabled}}
class="webhook-events__ping-button"
/>
<div class="web-hook-events-listing">
{{#if this.model}}
<LoadMore @selector=".web-hook-events li" @action={{action "loadMore"}}>
{{#if this.events}}
<LoadMore @selector=".web-hook-events li" @action={{this.loadMore}}>
<div class="web-hook-events content-list">
<div class="heading-container">
<div class="col heading first status">{{i18n "admin.web_hooks.events.status"}}</div>
@ -18,20 +19,22 @@
<div class="col heading timestamp">{{i18n "admin.web_hooks.events.timestamp"}}</div>
<div class="col heading completion">{{i18n "admin.web_hooks.events.completion"}}</div>
<div class="col heading actions">{{i18n "admin.web_hooks.events.actions"}}</div>
<div class="clearfix"></div>
</div>
{{#if this.hasIncoming}}
<a href tabindex="0" {{on "click" this.showInserted}} class="alert alert-info clickable">
<CountI18n @key="admin.web_hooks.events.incoming" @count={{this.incomingCount}} />
</a>
{{/if}}
<ul>
{{#each this.model as |webHookEvent|}}
{{#each this.events as |webHookEvent|}}
<AdminWebHookEvent @model={{webHookEvent}} />
{{/each}}
</ul>
</div>
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} />
<ConditionalLoadingSpinner @condition={{this.events.loadingMore}} />
</LoadMore>
{{else}}
<p>{{i18n "admin.web_hooks.events.none"}}</p>

View File

@ -0,0 +1,89 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import { gt, readOnly } from "@ember/object/computed";
import { bind } from "discourse-common/utils/decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default class WebhookEvents extends Component {
@service messageBus;
@service store;
@tracked pingEnabled = true;
@tracked events = [];
@tracked incomingEventIds = [];
@readOnly("incomingEventIds.length") incomingCount;
@gt("incomingCount", 0) hasIncoming;
constructor() {
super(...arguments);
this.loadEvents();
}
async loadEvents() {
this.events = await this.store.findAll(
"web-hook-event",
this.args.webhookId
);
}
@bind
subscribe() {
const channel = `/web_hook_events/${this.args.webhookId}`;
this.messageBus.subscribe(channel, this._addIncoming);
}
@bind
unsubscribe() {
this.messageBus.unsubscribe("/web_hook_events/*", this._addIncoming);
}
@bind
_addIncoming(data) {
if (data.event_type === "ping") {
this.pingEnabled = true;
}
if (!this.incomingEventIds.includes(data.web_hook_event_id)) {
this.incomingEventIds.pushObject(data.web_hook_event_id);
}
}
@action
async showInserted(event) {
event?.preventDefault();
const path = `/admin/api/web_hooks/${this.args.webhookId}/events/bulk`;
const data = await ajax(path, {
data: { ids: this.incomingEventIds },
});
const objects = data.map((webHookEvent) =>
this.store.createRecord("web-hook-event", webHookEvent)
);
this.events.unshiftObjects(objects);
this.incomingEventIds = [];
}
@action
loadMore() {
this.events.loadMore();
}
@action
async ping() {
this.pingEnabled = false;
try {
await ajax(`/admin/api/web_hooks/${this.args.webhookId}/ping`, {
type: "POST",
});
} catch (error) {
this.pingEnabled = true;
popupAjaxError(error);
}
}
}

View File

@ -0,0 +1,101 @@
import Controller, { inject as controller } from "@ember/controller";
import EmberObject, { action } from "@ember/object";
import I18n from "I18n";
import { alias } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators";
import { isEmpty } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
export default Controller.extend({
adminWebHooks: controller(),
dialog: service(),
eventTypes: alias("adminWebHooks.eventTypes"),
defaultEventTypes: alias("adminWebHooks.defaultEventTypes"),
contentTypes: alias("adminWebHooks.contentTypes"),
@discourseComputed
showTagsFilter() {
return this.siteSettings.tagging_enabled;
},
@discourseComputed("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 "";
},
@discourseComputed("model.isNew")
saveButtonText(isNew) {
return isNew
? I18n.t("admin.web_hooks.create")
: I18n.t("admin.web_hooks.save");
},
@discourseComputed("model.secret")
secretValidation(secret) {
if (!isEmpty(secret)) {
if (secret.includes(" ")) {
return EmberObject.create({
failed: true,
reason: I18n.t("admin.web_hooks.secret_invalid"),
});
}
if (secret.length < 12) {
return EmberObject.create({
failed: true,
reason: I18n.t("admin.web_hooks.secret_too_short"),
});
}
}
},
@discourseComputed("model.wildcard_web_hook", "model.web_hook_event_types.[]")
eventTypeValidation(isWildcard, eventTypes) {
if (!isWildcard && isEmpty(eventTypes)) {
return EmberObject.create({
failed: true,
reason: I18n.t("admin.web_hooks.event_type_missing"),
});
}
},
@discourseComputed(
"model.isSaving",
"secretValidation",
"eventTypeValidation",
"model.payload_url"
)
saveButtonDisabled(
isSaving,
secretValidation,
eventTypeValidation,
payloadUrl
) {
return isSaving
? false
: secretValidation || eventTypeValidation || isEmpty(payloadUrl);
},
@action
async save() {
this.set("saved", false);
try {
await this.model.save();
this.set("saved", true);
this.adminWebHooks.model.addObject(this.model);
this.transitionToRoute("adminWebHooks.show", this.model);
} catch (e) {
popupAjaxError(e);
}
},
});

View File

@ -0,0 +1,36 @@
import Controller, { inject as controller } from "@ember/controller";
import I18n from "I18n";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
import { alias } from "@ember/object/computed";
export default Controller.extend({
adminWebHooks: controller(),
dialog: service(),
contentTypes: alias("adminWebHooks.contentTypes"),
defaultEventTypes: alias("adminWebHooks.defaultEventTypes"),
deliveryStatuses: alias("adminWebHooks.deliveryStatuses"),
eventTypes: alias("adminWebHooks.eventTypes"),
model: alias("adminWebHooks.model"),
@action
destroy(webhook) {
return this.dialog.deleteConfirm({
message: I18n.t("admin.web_hooks.delete_confirm"),
didConfirm: async () => {
try {
await webhook.destroyRecord();
this.model.removeObject(webhook);
} catch (e) {
popupAjaxError(e);
}
},
});
},
@action
loadMore() {
this.model.loadMore();
},
});

View File

@ -1,81 +0,0 @@
import Controller from "@ember/controller";
import { ajax } from "discourse/lib/ajax";
import { action } from "@ember/object";
import { alias } from "@ember/object/computed";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";
export default Controller.extend({
pingDisabled: false,
incomingCount: alias("incomingEventIds.length"),
init() {
this._super(...arguments);
this.incomingEventIds = [];
},
@discourseComputed("incomingCount")
hasIncoming(incomingCount) {
return incomingCount > 0;
},
subscribe() {
this.messageBus.subscribe(
`/web_hook_events/${this.get("model.extras.web_hook_id")}`,
this._addIncoming
);
},
unsubscribe() {
this.messageBus.unsubscribe("/web_hook_events/*", this._addIncoming);
},
@bind
_addIncoming(data) {
if (data.event_type === "ping") {
this.set("pingDisabled", false);
}
if (!this.incomingEventIds.includes(data.web_hook_event_id)) {
this.incomingEventIds.pushObject(data.web_hook_event_id);
}
},
@action
showInserted(event) {
event?.preventDefault();
const webHookId = this.get("model.extras.web_hook_id");
ajax(`/admin/api/web_hooks/${webHookId}/events/bulk`, {
type: "GET",
data: { ids: this.incomingEventIds },
}).then((data) => {
const objects = data.map((webHookEvent) =>
this.store.createRecord("web-hook-event", webHookEvent)
);
this.model.unshiftObjects(objects);
this.set("incomingEventIds", []);
});
},
actions: {
loadMore() {
this.model.loadMore();
},
ping() {
this.set("pingDisabled", true);
ajax(
`/admin/api/web_hooks/${this.get("model.extras.web_hook_id")}/ping`,
{
type: "POST",
}
).catch((error) => {
this.set("pingDisabled", false);
popupAjaxError(error);
});
},
},
});

View File

@ -1,121 +1,32 @@
import Controller, { inject as controller } from "@ember/controller";
import EmberObject from "@ember/object";
import { action } from "@ember/object";
import I18n from "I18n";
import { alias } from "@ember/object/computed";
import discourseComputed from "discourse-common/utils/decorators";
import { isEmpty } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
export default Controller.extend({
adminWebHooks: controller(),
dialog: service(),
eventTypes: alias("adminWebHooks.eventTypes"),
defaultEventTypes: alias("adminWebHooks.defaultEventTypes"),
contentTypes: alias("adminWebHooks.contentTypes"),
router: service(),
@discourseComputed
showTagsFilter() {
return this.siteSettings.tagging_enabled;
},
@discourseComputed("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 "";
},
@discourseComputed("model.isNew")
saveButtonText(isNew) {
return isNew
? I18n.t("admin.web_hooks.create")
: I18n.t("admin.web_hooks.save");
},
@discourseComputed("model.secret")
secretValidation(secret) {
if (!isEmpty(secret)) {
if (secret.includes(" ")) {
return EmberObject.create({
failed: true,
reason: I18n.t("admin.web_hooks.secret_invalid"),
});
}
if (secret.length < 12) {
return EmberObject.create({
failed: true,
reason: I18n.t("admin.web_hooks.secret_too_short"),
});
}
}
},
@discourseComputed("model.wildcard_web_hook", "model.web_hook_event_types.[]")
eventTypeValidation(isWildcard, eventTypes) {
if (!isWildcard && isEmpty(eventTypes)) {
return EmberObject.create({
failed: true,
reason: I18n.t("admin.web_hooks.event_type_missing"),
});
}
},
@discourseComputed(
"model.isSaving",
"secretValidation",
"eventTypeValidation",
"model.payload_url"
)
saveButtonDisabled(
isSaving,
secretValidation,
eventTypeValidation,
payloadUrl
) {
return isSaving
? false
: secretValidation || eventTypeValidation || isEmpty(payloadUrl);
},
actions: {
save() {
this.set("saved", false);
const model = this.model;
const isNew = model.get("isNew");
return model
.save()
.then(() => {
this.set("saved", true);
this.adminWebHooks.get("model").addObject(model);
if (isNew) {
this.transitionToRoute("adminWebHooks.show", model.get("id"));
}
})
.catch(popupAjaxError);
@action
edit() {
return this.router.transitionTo("adminWebHooks.edit", this.model);
},
@action
destroy() {
return this.dialog.yesNoConfirm({
return this.dialog.deleteConfirm({
message: I18n.t("admin.web_hooks.delete_confirm"),
didConfirm: () => {
this.model
.destroyRecord()
.then(() => {
this.adminWebHooks.get("model").removeObject(this.model);
didConfirm: async () => {
try {
await this.model.destroyRecord();
this.adminWebHooks.model.removeObject(this.model);
this.transitionToRoute("adminWebHooks");
})
.catch(popupAjaxError);
} catch (e) {
popupAjaxError(e);
}
},
});
},
},
});

View File

@ -1,29 +1,3 @@
import Controller from "@ember/controller";
import I18n from "I18n";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { inject as service } from "@ember/service";
import { action } from "@ember/object";
export default Controller.extend({
dialog: service(),
@action
destroy(webhook) {
return this.dialog.yesNoConfirm({
message: I18n.t("admin.web_hooks.delete_confirm"),
didConfirm: () => {
webhook
.destroyRecord()
.then(() => {
this.model.removeObject(webhook);
})
.catch(popupAjaxError);
},
});
},
@action
loadMore() {
this.model.loadMore();
},
});
export default Controller.extend({});

View File

@ -123,7 +123,7 @@ export default function () {
{ path: "/web_hooks", resetNamespace: true },
function () {
this.route("show", { path: "/:web_hook_id" });
this.route("showEvents", { path: "/:web_hook_id/events" });
this.route("edit", { path: "/:web_hook_id/edit" });
}
);
});

View File

@ -0,0 +1,28 @@
import DiscourseRoute from "discourse/routes/discourse";
export default DiscourseRoute.extend({
serialize(model) {
return { web_hook_id: model.id || "new" };
},
model(params) {
if (params.web_hook_id === "new") {
return this.store.createRecord("web-hook");
}
return this.store.find("web-hook", params.web_hook_id);
},
setupController(controller, model) {
this._super(...arguments);
if (model.get("isNew")) {
model.set(
"web_hook_event_types",
this.controllerFor("adminWebHooks").defaultEventTypes
);
}
controller.set("saved", false);
},
});

View File

@ -1,21 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
import { get } from "@ember/object";
export default DiscourseRoute.extend({
model(params) {
return this.store.findAll("web-hook-event", get(params, "web_hook_id"));
},
setupController(controller, model) {
controller.set("model", model);
controller.subscribe();
},
deactivate() {
this.controllerFor("adminWebHooks.showEvents").unsubscribe();
},
renderTemplate() {
this.render("admin/templates/web-hooks-show-events", { into: "adminApi" });
},
});

View File

@ -1,30 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
import { get } from "@ember/object";
export default DiscourseRoute.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", get(params, "web_hook_id"));
},
setupController(controller, model) {
if (model.get("isNew")) {
model.set("web_hook_event_types", controller.get("defaultEventTypes"));
}
model.set("category_ids", model.get("category_ids"));
model.set("tag_names", model.get("tag_names"));
model.set("group_ids", model.get("group_ids"));
controller.setProperties({ model, saved: false });
},
renderTemplate() {
this.render("admin/templates/web-hooks-show", { into: "adminApi" });
return this.store.find("web-hook", params.web_hook_id);
},
});

View File

@ -1,4 +1,5 @@
import Route from "@ember/routing/route";
export default Route.extend({
model() {
return this.store.findAll("web-hook");

View File

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

View File

@ -0,0 +1,110 @@
<LinkTo @route="adminWebHooks" class="go-back">
{{d-icon "arrow-left"}}
{{i18n "admin.web_hooks.go_back"}}
</LinkTo>
<div class="web-hook-container">
<p>{{i18n "admin.web_hooks.detailed_instruction"}}</p>
<form class="web-hook form-horizontal">
<div class="control-group">
<label for="payload-url">{{i18n "admin.web_hooks.payload_url"}}</label>
<TextField @name="payload-url" @value={{this.model.payload_url}} @placeholderKey="admin.web_hooks.payload_url_placeholder" />
<InputTip @validation={{this.urlValidation}} />
</div>
<div class="control-group">
<label for="content-type">{{i18n "admin.web_hooks.content_type"}}</label>
<ComboBox @content={{this.contentTypes}} @name="content-type" @value={{this.model.content_type}} @onChange={{action (mut this.model.content_type)}} />
</div>
<div class="control-group">
<label for="secret">{{i18n "admin.web_hooks.secret"}}</label>
<TextField @name="secret" @value={{this.model.secret}} @placeholderKey="admin.web_hooks.secret_placeholder" />
<InputTip @validation={{this.secretValidation}} />
</div>
<div class="control-group">
<label>{{i18n "admin.web_hooks.event_chooser"}}</label>
<label>
<RadioButton @class="subscription-choice" @name="subscription-choice" @value="individual" @selection={{this.model.webHookType}} />
{{i18n "admin.web_hooks.individual_event"}}
<InputTip @validation={{this.eventTypeValidation}} />
</label>
{{#unless this.model.wildcard_web_hook}}
<div class="event-selector">
{{#each this.eventTypes as |type|}}
<AdminWebHookEventChooser @type={{type}} @model={{this.model.web_hook_event_types}} />
{{/each}}
</div>
{{/unless}}
<label>
<RadioButton @class="subscription-choice" @name="subscription-choice" @value="wildcard" @selection={{this.model.webHookType}} />
{{i18n "admin.web_hooks.wildcard_event"}}
</label>
</div>
<div class="filters control-group">
<div class="filter">
<label>{{d-icon "circle" class="tracking"}}{{i18n "admin.web_hooks.categories_filter"}}</label>
<CategorySelector @categories={{this.model.categories}} @onChange={{action (mut this.model.categories)}} />
<div class="instructions">{{i18n "admin.web_hooks.categories_filter_instructions"}}</div>
</div>
{{#if this.showTagsFilter}}
<div class="filter">
<label>{{d-icon "circle" class="tracking"}}{{i18n "admin.web_hooks.tags_filter"}}</label>
<TagChooser @tags={{this.model.tag_names}} @everyTag={{true}} @excludeSynonyms={{true}} />
<div class="instructions">{{i18n "admin.web_hooks.tags_filter_instructions"}}</div>
</div>
{{/if}}
<div class="filter">
<label>{{d-icon "circle" class="tracking"}}{{i18n "admin.web_hooks.groups_filter"}}</label>
<GroupSelector @groupNames={{this.model.groupsFilterInName}} @groupFinder={{this.model.groupFinder}} />
<div class="instructions">{{i18n "admin.web_hooks.groups_filter_instructions"}}</div>
</div>
</div>
<PluginOutlet @name="web-hook-fields" @tagName="span" @connectorTagName="div" @args={{hash model=this.model}} />
<label>
<Input @type="checkbox" name="verify_certificate" @checked={{this.model.verify_certificate}} />
{{i18n "admin.web_hooks.verify_certificate"}}
</label>
<div>
<label>
<Input @type="checkbox" name="active" @checked={{this.model.active}} />
{{i18n "admin.web_hooks.active"}}
</label>
{{#if this.model.active}}
<div class="instructions">{{i18n "admin.web_hooks.active_notice"}}</div>
{{/if}}
</div>
</form>
<div class="controls">
<DButton
@translatedLabel={{this.saveButtonText}}
@action={{action "save"}}
@disabled={{this.saveButtonDisabled}}
@class="btn-primary admin-webhooks__save-button"
/>
{{#if this.model.isNew}}
<LinkTo @route="adminWebHooks" class="btn btn-default">
{{i18n "cancel"}}
</LinkTo>
{{else}}
<LinkTo @route="adminWebHooks.show" @model={{this.model}} class="btn btn-default">
{{i18n "cancel"}}
</LinkTo>
{{/if}}
<span class="saving">{{this.savingStatus}}</span>
</div>
</div>

View File

@ -0,0 +1,68 @@
<div class="web-hooks-listing">
<p>{{i18n "admin.web_hooks.instruction"}}</p>
<div class="new-webhook">
<LinkTo
@route="adminWebHooks.edit"
@model="new"
class="btn btn-default admin-webhooks__new-button"
>
{{d-icon "plus"}}
{{i18n "admin.web_hooks.new"}}
</LinkTo>
</div>
{{#if this.model}}
<LoadMore @selector=".web-hooks tr" @action={{this.loadMore}}>
<table class="web-hooks grid">
<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_label"}}</th>
<th>{{i18n "admin.web_hooks.controls"}}</th>
</tr>
</thead>
<tbody>
{{#each this.model as |webHook|}}
<tr>
<td class="delivery-status">
<LinkTo @route="adminWebHooks.show" @model={{webHook}}>
<AdminWebHookStatus
@deliveryStatuses={{this.deliveryStatuses}}
@model={{webHook}}
/>
</LinkTo>
</td>
<td class="payload-url">
<LinkTo @route="adminWebHooks.edit" @model={{webHook}}>{{webHook.payload_url}}</LinkTo>
</td>
<td class="description">{{webHook.description}}</td>
<td class="controls">
<LinkTo
@route="adminWebHooks.edit"
@model={{webHook}}
class="btn btn-default no-text"
title={{i18n "admin.web_hooks.edit"}}
>
{{d-icon "far-edit"}}
</LinkTo>
<DButton
@class="destroy btn-danger"
@action={{this.destroy}}
@actionParam={{webHook}}
@icon="times"
@title="delete"
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} />
</LoadMore>
{{else}}
<p>{{i18n "admin.web_hooks.none"}}</p>
{{/if}}
</div>

View File

@ -3,90 +3,32 @@
{{i18n "admin.web_hooks.go_back"}}
</LinkTo>
<div class="web-hook-container">
<p>{{i18n "admin.web_hooks.detailed_instruction"}}</p>
<form class="web-hook form-horizontal">
<div class="control-group">
<label for="payload-url">{{i18n "admin.web_hooks.payload_url"}}</label>
<TextField @name="payload-url" @value={{this.model.payload_url}} @placeholderKey="admin.web_hooks.payload_url_placeholder" />
<InputTip @validation={{this.urlValidation}} />
</div>
<div class="admin-webhooks__summary">
<h1>
{{this.model.payload_url}}
<div class="control-group">
<label for="content-type">{{i18n "admin.web_hooks.content_type"}}</label>
<ComboBox @content={{this.contentTypes}} @name="content-type" @value={{this.model.content_type}} @onChange={{action (mut this.model.content_type)}} />
</div>
<DButton
@action={{this.edit}}
@icon="far-edit"
title={{i18n "admin.web_hooks.edit"}}
class="btn no-text admin-webhooks__edit-button"
/>
<div class="control-group">
<label for="secret">{{i18n "admin.web_hooks.secret"}}</label>
<TextField @name="secret" @value={{this.model.secret}} @placeholderKey="admin.web_hooks.secret_placeholder" />
<InputTip @validation={{this.secretValidation}} />
</div>
<div class="control-group">
<label>{{i18n "admin.web_hooks.event_chooser"}}</label>
<div>
<RadioButton @class="subscription-choice" @name="subscription-choice" @value="individual" @selection={{this.model.webHookType}} />
{{i18n "admin.web_hooks.individual_event"}}
<InputTip @validation={{this.eventTypeValidation}} />
</div>
{{#unless this.model.wildcard_web_hook}}
<div class="event-selector">
{{#each this.eventTypes as |type|}}
<AdminWebHookEventChooser @type={{type}} @model={{this.model.web_hook_event_types}} />
{{/each}}
</div>
{{/unless}}
<div>
<RadioButton @class="subscription-choice" @name="subscription-choice" @value="wildcard" @selection={{this.model.webHookType}} />
{{i18n "admin.web_hooks.wildcard_event"}}
</div>
</div>
<div class="filters control-group">
<div class="filter">
<label>{{d-icon "circle" class="tracking"}}{{i18n "admin.web_hooks.categories_filter"}}</label>
<CategorySelector @categories={{this.model.categories}} @onChange={{action (mut this.model.categories)}} />
<div class="instructions">{{i18n "admin.web_hooks.categories_filter_instructions"}}</div>
</div>
{{#if this.showTagsFilter}}
<div class="filter">
<label>{{d-icon "circle" class="tracking"}}{{i18n "admin.web_hooks.tags_filter"}}</label>
<TagChooser @tags={{this.model.tag_names}} @everyTag={{true}} @excludeSynonyms={{true}} />
<div class="instructions">{{i18n "admin.web_hooks.tags_filter_instructions"}}</div>
</div>
{{/if}}
<div class="filter">
<label>{{d-icon "circle" class="tracking"}}{{i18n "admin.web_hooks.groups_filter"}}</label>
<GroupSelector @groupNames={{this.model.groupsFilterInName}} @groupFinder={{this.model.groupFinder}} />
<div class="instructions">{{i18n "admin.web_hooks.groups_filter_instructions"}}</div>
</div>
</div>
<PluginOutlet @name="web-hook-fields" @tagName="span" @connectorTagName="div" @args={{hash model=this.model}} />
<DButton
@action={{this.destroy}}
@icon="times"
@title="delete"
class="destroy btn-danger admin-webhooks__delete-button"
/>
</h1>
<div>
<Input @type="checkbox" name="verify_certificate" @checked={{this.model.verify_certificate}} /> {{i18n "admin.web_hooks.verify_certificate"}}
</div>
<div>
<div>
<Input @type="checkbox" name="active" @checked={{this.model.active}} /> {{i18n "admin.web_hooks.active"}}
</div>
{{#if this.model.active}}
<div class="instructions">{{i18n "admin.web_hooks.active_notice"}}</div>
{{/if}}
</div>
</form>
<span class="admin-webhooks__description-label">
{{i18n "admin.web_hooks.description_label"}}:
</span>
<div class="controls">
<DButton @class="btn-default" @translatedLabel={{this.saveButtonText}} @action={{action "save"}} @disabled={{this.saveButtonDisabled}} />
{{#unless this.model.isNew}}
<DButton @class="btn-danger" @label="admin.web_hooks.destroy" @action={{action "destroy"}} />
<LinkTo @route="adminWebHooks.showEvents" @model={{this.model.id}} class="btn">
{{i18n "admin.web_hooks.events.go_events"}}
</LinkTo>
{{/unless}}
<span class="saving">{{this.savingStatus}}</span>
{{this.model.description}}
</div>
</div>
<WebhookEvents @webhookId={{this.model.id}} />

View File

@ -1,38 +1 @@
<div class="web-hooks-listing">
<p>{{i18n "admin.web_hooks.instruction"}}</p>
<div class="new-webhook">
<LinkTo @route="adminWebHooks.show" @model="new" class="btn btn-default">
{{d-icon "plus"}} {{i18n "admin.web_hooks.new"}}
</LinkTo>
</div>
{{#if this.model}}
<LoadMore @selector=".web-hooks tr" @action={{action "loadMore"}}>
<table class="web-hooks grid">
<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 this.model as |webHook|}}
<tr>
<td class="delivery-status"><LinkTo @route="adminWebHooks.showEvents" @model={{webHook.id}}><AdminWebHookStatus @deliveryStatuses={{this.deliveryStatuses}} @model={{webHook}} /></LinkTo></td>
<td class="payload-url"><LinkTo @route="adminWebHooks.show" @model={{webHook}}>{{webHook.payload_url}}</LinkTo></td>
<td class="description">{{webHook.description}}</td>
<td class="controls">
<LinkTo @route="adminWebHooks.show" @model={{webHook}} class="btn btn-default no-text">{{d-icon "far-edit"}}</LinkTo>
<DButton @class="destroy btn-danger" @action={{action "destroy"}} @actionParam={{webHook}} @icon="times" />
</td>
</tr>
{{/each}}
</tbody>
</table>
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} />
</LoadMore>
{{else}}
<p>{{i18n "admin.web_hooks.none"}}</p>
{{/if}}
</div>
{{outlet}}

View File

@ -0,0 +1,72 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import pretender, {
parsePostData,
response,
} from "discourse/tests/helpers/create-pretender";
acceptance("Admin - Webhooks", function (needs) {
needs.user();
test("adding a webhook", async function (assert) {
pretender.get("/admin/api/web_hooks", () => {
return response({
web_hooks: [],
total_rows_web_hooks: 0,
load_more_web_hooks: "/admin/api/web_hooks.json?limit=50&offset=50",
extras: {
content_types: [
{ id: 1, name: "application/json" },
{ id: 2, name: "application/x-www-form-urlencoded" },
],
default_event_types: [{ id: 2, name: "post" }],
delivery_statuses: [
{ id: 1, name: "inactive" },
{ id: 2, name: "failed" },
{ id: 3, name: "successful" },
],
event_types: [
{ id: 1, name: "topic" },
{ id: 2, name: "post" },
{ id: 3, name: "user" },
{ id: 4, name: "group" },
],
},
});
});
pretender.get("/admin/api/web_hook_events/1", () => {
return response({
web_hook_events: [],
load_more_web_hook_events:
"/admin/api/web_hook_events/1.json?limit=50&offset=50",
total_rows_web_hook_events: 15,
extras: { web_hook_id: 1 },
});
});
pretender.post("/admin/api/web_hooks", (request) => {
const data = parsePostData(request.requestBody);
assert.strictEqual(
data.web_hook.payload_url,
"https://example.com/webhook"
);
return response({
web_hook: {
id: 1,
// other attrs
},
});
});
await visit("/admin/api/web_hooks");
await click(".admin-webhooks__new-button");
await fillIn(`[name="payload-url"`, "https://example.com/webhook");
await click(".admin-webhooks__save-button");
assert.strictEqual(currentURL(), "/admin/api/web_hooks/1");
});
});

View File

@ -265,14 +265,21 @@ table.api-keys {
}
}
.web-hook-direction {
a,
button {
margin-right: 10px;
}
.admin-webhooks__summary {
margin-bottom: 1rem;
}
.admin-webhooks__edit-button,
.admin-webhooks__delete-button {
font-size: var(--font-0-rem);
}
.web-hook-events {
.heading-container {
width: 100%;
background-color: var(--primary-low);
}
li {
padding: 2px 0;
}
@ -283,6 +290,13 @@ table.api-keys {
overflow-y: auto;
overflow-x: hidden;
}
.col.heading {
font-weight: bold;
padding: 4px 0;
}
.col.heading.actions {
padding: 4px 0;
}
.col.first {
width: 90px;
}
@ -296,18 +310,14 @@ table.api-keys {
width: 250px;
}
.col.actions {
width: 455px;
padding-top: 0;
a {
text-decoration: underline;
}
}
.col.heading.actions {
padding: 4px 0;
}
.details {
display: block;
margin-top: 10px;
margin-top: 1rem;
}
label {
font-size: var(--font-0);
@ -317,10 +327,13 @@ table.api-keys {
}
}
.webhook-events__ping-button {
margin-bottom: 1rem;
}
.web-hook-events-listing {
margin-top: 15px;
.alert {
margin: 15px 0 0 0;
margin: 0;
}
}

View File

@ -1,28 +1,5 @@
// Styles for /admin/logs
.web-hook-events {
border-bottom: dotted 1px var(--primary-low-mid);
.heading-container {
width: 100%;
background-color: var(--primary-low);
}
.col.heading {
font-weight: bold;
padding: 4px 0;
}
.col {
display: inline-block;
padding-top: 6px;
vertical-align: top;
overflow-y: auto;
overflow-x: hidden;
}
.ember-list-item-view {
width: 100%;
border-top: solid 1px var(--primary-low);
}
}
.log-details-modal {
pre {
white-space: pre-wrap;

View File

@ -32,6 +32,10 @@ class Admin::WebHooksController < Admin::AdminController
render_serialized(@web_hook, AdminWebHookSerializer, root: 'web_hook')
end
def edit
render_serialized(@web_hook, AdminWebHookSerializer, root: 'web_hook')
end
def create
web_hook = WebHook.new(web_hook_params)
@ -58,9 +62,6 @@ class Admin::WebHooksController < Admin::AdminController
render json: success_json
end
def new
end
def list_events
limit = 50
offset = params[:offset].to_i

View File

@ -4724,9 +4724,9 @@ en:
detailed_instruction: "A POST request will be sent to provided URL when chosen event happens."
new: "New Webhook"
create: "Create"
edit: "Edit"
save: "Save"
destroy: "Delete"
description: "Description"
description_label: "Event triggers"
controls: "Controls"
go_back: "Back to list"
payload_url: "Payload URL"
@ -4807,9 +4807,6 @@ en:
headers: "Headers"
payload: "Payload"
body: "Body"
go_list: "Go to list"
go_details: "Edit webhook"
go_events: "Go to events"
ping: "Ping"
status: "Status Code"
event_id: "ID"

View File

@ -300,7 +300,6 @@ Discourse::Application.routes.draw do
resources :web_hooks
get 'web_hook_events/:id' => 'web_hooks#list_events', as: :web_hook_events
get 'web_hooks/:id/events' => 'web_hooks#list_events'
get 'web_hooks/:id/events/bulk' => 'web_hooks#bulk_events'
post 'web_hooks/:web_hook_id/events/:event_id/redeliver' => 'web_hooks#redeliver_event'
post 'web_hooks/:id/ping' => 'web_hooks#ping'