Admin webhooks UI guidelines (#30764)

This change converts the admin webhooks UI to the new UI guidelines and modernizes the JS.
This commit is contained in:
Ted Johansson 2025-01-16 10:22:18 +08:00 committed by GitHub
parent 35507d4090
commit 5c0b7c4d70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 667 additions and 387 deletions

View File

@ -0,0 +1,301 @@
import Component from "@glimmer/component";
import { cached, tracked } from "@glimmer/tracking";
import { concat, hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { eq } from "truth-helpers";
import BackButton from "discourse/components/back-button";
import ConditionalLoadingSection from "discourse/components/conditional-loading-section";
import Form from "discourse/components/form";
import GroupSelector from "discourse/components/group-selector";
import PluginOutlet from "discourse/components/plugin-outlet";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import WebhookEventChooser from "admin/components/webhook-event-chooser";
import CategorySelector from "select-kit/components/category-selector";
import TagChooser from "select-kit/components/tag-chooser";
export default class AdminConfigAreasWebhookForm extends Component {
@service router;
@service siteSettings;
@service store;
@tracked webhook = this.args.webhook;
@tracked loadingExtras = true;
@tracked webhookEventTypes = [];
@tracked defaultEventTypes = {};
@tracked groupedEventTypes = {};
@tracked contentTypes = [];
@tracked deliveryStatuses = [];
constructor() {
super(...arguments);
this.#loadExtras();
}
@cached
get formData() {
return {
payload_url: this.webhook.payload_url,
content_type: this.webhook.content_type,
secret: this.webhook.secret === "" ? null : this.webhook.secret,
categories: this.webhook.categories,
group_names: this.webhook.group_names,
tag_names: this.webhook.tag_names,
wildcard: this.webhook.wildcard,
web_hook_event_types: this.webhook.web_hook_event_types,
verify_certificate: this.webhook.verify_certificate,
active: this.webhook.active,
};
}
async #loadExtras() {
try {
this.loadingExtras = true;
const webhooks = await this.store.findAll("web-hook");
this.groupedEventTypes = webhooks.extras.grouped_event_types;
this.defaultEventTypes = webhooks.extras.default_event_types;
this.contentTypes = webhooks.extras.content_types;
this.deliveryStatuses = webhooks.extras.delivery_statuses;
if (this.webhook.isNew) {
this.webhookEventTypes = [...this.defaultEventTypes];
} else {
this.webhookEventTypes = [...this.webhook.web_hook_event_types];
}
} finally {
this.loadingExtras = false;
}
}
get showTagsFilter() {
return this.siteSettings.tagging_enabled;
}
get saveButtonLabel() {
return this.webhook.isNew
? "admin.web_hooks.create"
: "admin.web_hooks.save";
}
@action
async save(data) {
try {
const isNew = this.webhook.isNew;
this.webhook.setProperties({
...data,
web_hook_event_types: this.webhookEventTypes,
});
await this.webhook.save();
if (isNew) {
this.router.transitionTo("adminWebHooks.show", this.webhook);
} else {
this.router.transitionTo("adminWebHooks.index");
}
} catch (e) {
popupAjaxError(e);
}
}
<template>
<BackButton @route="adminWebHooks.index" @label="admin.web_hooks.back" />
<div class="admin-config-area user-field">
<div class="admin-config-area__primary-content">
<div class="admin-config-area-card">
<div class="web-hook-container">
<ConditionalLoadingSection @isLoading={{this.loadingExtras}}>
<p>{{i18n "admin.web_hooks.detailed_instruction"}}</p>
<Form
@onSubmit={{this.save}}
@data={{this.formData}}
as |form transientData|
>
<form.Field
@name="payload_url"
@title={{i18n "admin.web_hooks.payload_url"}}
@format="large"
@validation="required|url"
as |field|
>
<field.Input
placeholder={{i18n
"admin.web_hooks.payload_url_placeholder"
}}
/>
</form.Field>
<form.Field
@name="content_type"
@title={{i18n "admin.web_hooks.content_type"}}
@format="large"
@validation="required"
as |field|
>
<field.Select as |select|>
{{#each this.contentTypes as |contentType|}}
<select.Option
@value={{contentType.id}}
>{{contentType.name}}</select.Option>
{{/each}}
</field.Select>
</form.Field>
<form.Field
@name="secret"
@title={{i18n "admin.web_hooks.secret"}}
@description={{i18n "admin.web_hooks.secret_placeholder"}}
@format="large"
@validation="length:12"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="wildcard"
@title={{i18n "admin.web_hooks.event_chooser"}}
@validation="required"
@onSet={{this.setRequirement}}
@format="full"
as |field|
>
<field.RadioGroup as |radioGroup|>
<radioGroup.Radio @value="individual">
{{i18n "admin.web_hooks.individual_event"}}
</radioGroup.Radio>
{{#if (eq transientData.wildcard "individual")}}
<div class="event-selector">
{{#each-in
this.groupedEventTypes
as |group eventTypes|
}}
<div class="event-group">
{{i18n
(concat
"admin.web_hooks." group "_event.group_name"
)
}}
{{#each eventTypes as |type|}}
<WebhookEventChooser
@type={{type}}
@group={{group}}
@eventTypes={{this.webhookEventTypes}}
/>
{{/each}}
</div>
{{/each-in}}
</div>
{{/if}}
<radioGroup.Radio @value="wildcard">
{{i18n "admin.web_hooks.wildcard_event"}}
</radioGroup.Radio>
</field.RadioGroup>
</form.Field>
<form.Field
@name="categories"
@title={{i18n "admin.web_hooks.categories_filter"}}
@description={{i18n
"admin.web_hooks.categories_filter_instructions"
}}
@format="large"
as |field|
>
<field.Custom>
<CategorySelector
@categories={{field.value}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
{{#if this.showTagsFilter}}
<form.Field
@name="tag_names"
@title={{i18n "admin.web_hooks.tags_filter"}}
@description={{i18n
"admin.web_hooks.tags_filter_instructions"
}}
@format="large"
as |field|
>
<field.Custom>
<TagChooser
@tags={{field.value}}
@everyTag={{true}}
@excludeSynonyms={{true}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
{{/if}}
<form.Field
@name="group_names"
@title={{i18n "admin.web_hooks.groups_filter"}}
@description={{i18n
"admin.web_hooks.groups_filter_instructions"
}}
@format="large"
as |field|
>
<field.Custom>
<GroupSelector
@groupNames={{field.value}}
@groupFinder={{this.webhook.groupFinder}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
<span>
<PluginOutlet
@name="web-hook-fields"
@connectorTagName="div"
@outletArgs={{hash model=this.webhook}}
/>
</span>
<form.Field
@name="verify_certificate"
@title={{i18n "admin.web_hooks.verify_certificate"}}
@showTitle={{false}}
as |field|
>
<field.Checkbox />
</form.Field>
<form.Field
@name="active"
@title={{i18n "admin.web_hooks.active"}}
@showTitle={{false}}
as |field|
>
<field.Checkbox />
</form.Field>
<form.Actions>
<form.Submit class="save" @label={{this.saveButtonLabel}} />
<form.Button
@route="adminWebHooks.index"
@label="admin.web_hooks.cancel"
class="btn-default"
/>
</form.Actions>
</Form>
</ConditionalLoadingSection>
</div>
</div>
</div>
</div>
</template>
}

View File

@ -0,0 +1,62 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import WebhookItem from "admin/components/webhook-item";
export default class AdminConfigAreasWebhooksList extends Component {
@service dialog;
@tracked webhooks = this.args.webhooks;
@action
destroyWebhook(webhook) {
return this.dialog.deleteConfirm({
message: i18n("admin.web_hooks.delete_confirm"),
didConfirm: async () => {
try {
await webhook.destroyRecord();
this.webhooks.removeObject(webhook);
} catch (e) {
popupAjaxError(e);
}
},
});
}
<template>
<div class="container admin-api_keys">
{{#if this.webhooks}}
<table class="d-admin-table admin-web_hooks__items">
<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.webhooks as |webhook|}}
<WebhookItem
@webhook={{webhook}}
@deliveryStatuses={{this.webhooks.extras.delivery_statuses}}
@destroy={{this.destroyWebhook}}
/>
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="admin.web_hooks.add"
@ctaRoute="adminWebHooks.new"
@ctaClass="admin-web_hooks__add-web_hook"
@emptyLabel="admin.web_hooks.none"
/>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,93 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { LinkTo } from "@ember/routing";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import { i18n } from "discourse-i18n";
import WebhookStatus from "admin/components/webhook-status";
import DMenu from "float-kit/components/d-menu";
export default class WebhookItem extends Component {
@service router;
@tracked webhook = this.args.webhook;
deliveryStatuses = this.args.deliveryStatuses;
@action
onRegisterApi(api) {
this.dMenu = api;
}
@action
edit() {
this.router.transitionTo("adminWebHooks.edit", this.webhook);
}
@action
events() {
this.router.transitionTo("adminWebHooks.show", this.webhook);
}
<template>
<tr class="d-admin-row__content">
<td class="d-admin-row__overview key">
<LinkTo @route="adminWebHooks.show" @model={{this.webhook}}>
<WebhookStatus
@deliveryStatuses={{this.deliveryStatuses}}
@webhook={{this.webhook}}
/>
</LinkTo>
</td>
<td class="d-admin-row__detail">
<LinkTo @route="adminWebHooks.edit" @model={{this.webhook}}>
{{this.webhook.payload_url}}
</LinkTo>
</td>
<td class="d-admin-row__detail">
{{this.webhook.description}}
</td>
<td class="d-admin-row__controls key-controls">
<div class="d-admin-row__controls-options">
<DButton
@action={{this.edit}}
@label="admin.web_hooks.edit"
@title="admin.api.show_details"
class="btn-small"
/>
<DMenu
@identifier="webhook-menu"
@title={{i18n "admin.config_areas.user_fields.more_options.title"}}
@icon="ellipsis-vertical"
@onRegisterApi={{this.onRegisterApi}}
>
<:content>
<DropdownMenu as |dropdown|>
<dropdown.item>
<DButton
@action={{this.events}}
@icon="list"
@label="admin.web_hooks.show"
@title="admin.web_hooks.show"
class="admin-web-hook__show"
/>
</dropdown.item>
<dropdown.item>
<DButton
@action={{fn @destroy this.webhook}}
@icon="trash-can"
@label="admin.web_hooks.delete"
@title="admin.web_hooks.delete"
class="btn-danger admin-web-hook__delete"
/>
</dropdown.item>
</DropdownMenu>
</:content>
</DMenu>
</div>
</td>
</tr>
</template>
}

View File

@ -41,7 +41,7 @@ export default class WebHook extends RestModel {
verify_certificate = true;
active = false;
web_hook_event_types = null;
groupsFilterInName = null;
group_names = null;
@computed("wildcard_web_hook")
get wildcard() {
@ -73,7 +73,7 @@ export default class WebHook extends RestModel {
updateGroupsFilter() {
const groupIds = this.group_ids;
this.set(
"groupsFilterInName",
"group_names",
Site.currentProp("groups").reduce((groupNames, g) => {
if (groupIds.includes(g.id)) {
groupNames.push(g.name);
@ -106,7 +106,7 @@ export default class WebHook extends RestModel {
// 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.groupsFilterInName;
const groupsFilter = this.group_names;
const groupNames =
typeof groupsFilter === "string" ? groupsFilter.split(",") : groupsFilter;

View File

@ -130,6 +130,7 @@ export default function () {
"adminWebHooks",
{ path: "/web_hooks", resetNamespace: true },
function () {
this.route("new");
this.route("show", { path: "/:web_hook_id" });
this.route("edit", { path: "/:web_hook_id/edit" });
}

View File

@ -1,28 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminWebHooksEditRoute extends DiscourseRoute {
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) {
super.setupController(...arguments);
if (model.get("isNew")) {
model.set(
"web_hook_event_types",
this.controllerFor("adminWebHooks").defaultEventTypes
);
}
controller.set("saved", false);
}
}

View File

@ -0,0 +1,7 @@
import Route from "@ember/routing/route";
export default class AdminWebHooksIndexRoute extends Route {
model() {
return this.store.findAll("web-hook");
}
}

View File

@ -0,0 +1,7 @@
import DiscourseRoute from "discourse/routes/discourse";
export default class AdminWebHooksNewRoute extends DiscourseRoute {
model() {
return this.store.createRecord("web-hook");
}
}

View File

@ -1,17 +1,3 @@
import Route from "@ember/routing/route";
export default class AdminWebHooksRoute extends Route {
model() {
return this.store.findAll("web-hook");
}
setupController(controller, model) {
controller.setProperties({
model,
groupedEventTypes: model.extras.grouped_event_types,
defaultEventTypes: model.extras.default_event_types,
contentTypes: model.extras.content_types,
deliveryStatuses: model.extras.delivery_statuses,
});
}
}
export default class AdminWebHooksRoute extends Route {}

View File

@ -1,179 +1 @@
<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={{fn (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 class="subscription-choice">
<RadioButton
@name="subscription-choice"
@onChange={{fn (mut this.model.wildcard_web_hook) false}}
@value={{false}}
@selection={{this.model.wildcard_web_hook}}
/>
{{i18n "admin.web_hooks.individual_event"}}
<InputTip @validation={{this.eventTypeValidation}} />
</label>
{{#unless this.model.wildcard_web_hook}}
<div class="event-selector">
{{#each-in this.groupedEventTypes as |group eventTypes|}}
<div class="event-group">
{{i18n (concat "admin.web_hooks." group "_event.group_name")}}
{{#each eventTypes as |type|}}
<WebhookEventChooser
@type={{type}}
@group={{group}}
@eventTypes={{this.model.web_hook_event_types}}
/>
{{/each}}
</div>
{{/each-in}}
</div>
{{/unless}}
<label class="subscription-choice">
<RadioButton
@name="subscription-choice"
@onChange={{fn (mut this.model.wildcard_web_hook) true}}
@value={{true}}
@selection={{this.model.wildcard_web_hook}}
/>
{{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={{fn (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>
<span>
<PluginOutlet
@name="web-hook-fields"
@connectorTagName="div"
@outletArgs={{hash model=this.model}}
/>
</span>
<label>
<Input
@type="checkbox"
name="verify_certificate"
@checked={{this.model.verify_certificate}}
/>
{{i18n "admin.web_hooks.verify_certificate"}}
</label>
<div>
<label class="checkbox-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={{this.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>
<AdminConfigAreas::WebhooksForm @webhook={{this.model}} />

View File

@ -1,80 +1 @@
<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="d-admin-table web-hooks">
<thead>
<tr>
<th>{{i18n "admin.web_hooks.payload_url"}}</th>
<th>{{i18n "admin.web_hooks.description_label"}}</th>
<th>{{i18n "admin.web_hooks.delivery_status.title"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.model as |webhook|}}
<tr class="d-admin-row__content">
<td class="d-admin-row__overview payload-url">
<LinkTo @route="adminWebHooks.edit" @model={{webhook}}>
{{webhook.payload_url}}
</LinkTo>
</td>
<td class="d-admin-row__detail description">
<div class="d-admin-row__mobile-label">
{{i18n "admin.web_hooks.description_label"}}
</div>
{{webhook.description}}
</td>
<td class="d-admin-row__detail delivery-status">
<div class="d-admin-row__mobile-label">
{{i18n "admin.web_hooks.delivery_status.title"}}
</div>
<LinkTo @route="adminWebHooks.show" @model={{webhook}}>
<WebhookStatus
@deliveryStatuses={{this.deliveryStatuses}}
@webhook={{webhook}}
/>
</LinkTo>
</td>
<td class="d-admin-row__controls controls">
<div class="d-admin-row__controls-options">
<LinkTo
@route="adminWebHooks.edit"
@model={{webhook}}
class="btn btn-default no-text"
title={{i18n "admin.web_hooks.edit"}}
>
{{d-icon "far-pen-to-square"}}
</LinkTo>
<DButton
@action={{fn this.destroyWebhook webhook}}
@icon="xmark"
@title="delete"
class="destroy btn-danger"
/>
</div>
</td>
</tr>
{{/each}}
</tbody>
</table>
<ConditionalLoadingSpinner @condition={{this.model.loadingMore}} />
</LoadMore>
{{else}}
<p>{{i18n "admin.web_hooks.none"}}</p>
{{/if}}
</div>
<AdminConfigAreas::WebhooksList @webhooks={{this.model}} />

View File

@ -0,0 +1 @@
<AdminConfigAreas::WebhooksForm @webhook={{this.model}} />

View File

@ -1,6 +1,6 @@
<LinkTo @route="adminWebHooks" class="go-back">
{{d-icon "arrow-left"}}
{{i18n "admin.web_hooks.go_back"}}
{{i18n "admin.web_hooks.back"}}
</LinkTo>
<div class="admin-webhooks__summary">

View File

@ -0,0 +1,35 @@
import RouteTemplate from "ember-route-template";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DPageHeader from "discourse/components/d-page-header";
import PluginOutlet from "discourse/components/plugin-outlet";
import { i18n } from "discourse-i18n";
export default RouteTemplate(<template>
<div class="admin-webhooks admin-config-page">
<DPageHeader
@titleLabel={{i18n "admin.web_hooks.title"}}
@descriptionLabel={{i18n "admin.web_hooks.instruction"}}
@hideTabs={{true}}
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/api/web_hooks"
@label={{i18n "admin.web_hooks.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@route="adminWebHooks.new"
@label="admin.web_hooks.add"
/>
</:actions>
</DPageHeader>
<div class="admin-container admin-config-page__main-area">
<PluginOutlet @name="admin-web-hooks">
{{outlet}}
</PluginOutlet>
</div>
</div>
</template>);

View File

@ -1,3 +0,0 @@
<PluginOutlet @name="admin-web-hooks">
{{outlet}}
</PluginOutlet>

View File

@ -38,7 +38,9 @@ export default class GroupSelector extends Component {
onChangeItems: (items) => {
selectedGroups = items;
if (this.onChangeCallback) {
if (this.onChange) {
this.onChange(items.join(","));
} else if (this.onChangeCallback) {
this.onChangeCallback(this.groupNames, selectedGroups);
} else {
this.set("groupNames", items.join(","));

View File

@ -1,78 +0,0 @@
import { click, currentURL, fillIn, visit } from "@ember/test-helpers";
import { test } from "qunit";
import pretender, {
parsePostData,
response,
} from "discourse/tests/helpers/create-pretender";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
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: {
categories: [],
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");
const categorySelector = selectKit(".category-selector");
await categorySelector.expand();
categorySelector.selectRowByName("support");
await click(".admin-webhooks__save-button");
assert.strictEqual(currentURL(), "/admin/api/web_hooks/1");
});
});

View File

@ -5542,27 +5542,30 @@ en:
web_hooks:
title: "Webhooks"
none: "There are no webhooks right now."
none: "You don't have any webhooks yet"
instruction: "Webhooks allows Discourse to notify external services when certain event happens in your site. When the webhook is triggered, a POST request will send to URLs provided."
detailed_instruction: "A POST request will be sent to provided URL when chosen event happens."
new: "New Webhook"
add: "Add webhook"
create: "Create"
edit: "Edit"
save: "Save"
show: "Events"
delete: "Delete"
cancel: "Cancel"
description_label: "Event triggers"
controls: "Controls"
go_back: "Back to list"
back: "Back to webhooks"
payload_url: "Payload URL"
payload_url_placeholder: "https://example.com/postreceive"
secret_invalid: "Secret must not have any blank characters."
secret_too_short: "Secret should be at least 12 characters."
secret_placeholder: "An optional string, used for generating signature"
secret_placeholder: "String used for generating signatures"
event_type_missing: "You need to set up at least one event type."
content_type: "Content Type"
secret: "Secret"
event_chooser: "Which events should trigger this webhook?"
wildcard_event: "Send me everything."
individual_event: "Select individual events."
wildcard_event: "Send me everything"
individual_event: "Select individual events"
verify_certificate: "Check TLS certificate of payload url"
active: "Active"
active_notice: "We will deliver event details when it happens."

View File

@ -0,0 +1,48 @@
#frozen_string_literal: true
describe "Admin Webhooks Page", type: :system do
fab!(:admin)
let(:webhooks_page) { PageObjects::Pages::AdminWebhooks.new }
let(:dialog) { PageObjects::Components::Dialog.new }
before do
Fabricate(:web_hook, payload_url: "https://www.example.com/1")
sign_in(admin)
end
it "shows a list of webhooks" do
webhooks_page.visit_page
expect(webhooks_page).to have_webhook_listed("https://www.example.com/1")
end
it "can add a new webhook" do
webhooks_page.visit_page
webhooks_page.add_webhook(payload_url: "https://www.example.com/2")
expect(webhooks_page).to have_webhook_details("https://www.example.com/2")
webhooks_page.click_back
expect(webhooks_page).to have_webhook_listed("https://www.example.com/2")
end
it "can edit existing webhooks" do
webhooks_page.visit_page
webhooks_page.click_edit("https://www.example.com/1")
webhooks_page.edit_payload_url("https://www.example.com/3")
expect(webhooks_page).to have_webhook_listed("https://www.example.com/3")
end
it "can delete webhooks" do
webhooks_page.visit_page
webhooks_page.click_delete("https://www.example.com/1")
dialog.click_danger
expect(webhooks_page).to have_no_webhook_listed("https://www.example.com/1")
end
end

View File

@ -0,0 +1,93 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminWebhooks < PageObjects::Pages::Base
def visit_page
page.visit("/admin/api/web_hooks")
self
end
def has_webhook_listed?(url)
page.has_css?(table_selector, text: url)
end
def has_no_webhook_listed?(url)
page.has_no_css?(table_selector, text: url)
end
def has_webhook_details?(url)
page.has_css?(summary_selector, text: url)
end
def add_webhook(payload_url:)
page.find(header_actions_selector, text: I18n.t("admin_js.admin.web_hooks.add")).click
form = page.find(form_selector)
form.find(payload_url_field_selector).fill_in(with: payload_url)
click_save
end
def edit_payload_url(payload_url)
form = page.find(form_selector)
form.find(payload_url_field_selector).fill_in(with: payload_url)
click_save
end
def click_back
page.find(back_button_selector).click
end
def click_save
page.find(save_button_selector).click
end
def click_edit(payload_url)
row = page.find(row_selector, text: payload_url)
row.find("button", text: I18n.t("admin_js.admin.web_hooks.edit")).click
end
def click_delete(payload_url)
row = page.find(row_selector, text: payload_url)
row.find(".webhook-menu-trigger").click
page.find(".admin-web-hook__delete").click
end
private
def table_selector
".admin-web_hooks__items"
end
def row_selector
".d-admin-row__content"
end
def form_selector
".form-kit"
end
def summary_selector
".admin-webhooks__summary"
end
def header_actions_selector
".d-page-header__actions"
end
def payload_url_field_selector
"input[name='payload_url']"
end
def save_button_selector
".save"
end
def back_button_selector
".go-back"
end
end
end
end