FEATURE: Convert chat plugin UI to new show plugin and admin UI guidelines (#28632)

This commit converts the current chat plugin UI into the
new "show plugin" UI already followed by AI and Gamification.

In the process, I also:

* Made a dedicated /new route to create new webhooks
* Converted the webhook form to FormKit
* Made some fixes and improvements to the `AdminPluginConfigPage`, `AdminPageHeader`,
   and `AdminPageSubheader` generic components, so more plugins can
   adopt the UI guidelines too. This includes adding a header outlet so plugins
   can add action buttons to the plugin show page header.
* Fixes the submit button loading state for FormKit (by Joffrey)

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
This commit is contained in:
Martin Brennan 2024-09-10 15:16:16 +10:00 committed by GitHub
parent 56877e9acf
commit 61c1d35f17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 828 additions and 538 deletions

View File

@ -127,15 +127,17 @@ export default class AdminFlagItem extends Component {
}}</p>
</td>
<td>
<div class="admin-flag-item__options">
<DToggleSwitch
@state={{this.enabled}}
class="admin-flag-item__toggle {{@flag.name_key}}"
{{on "click" (fn this.toggleFlagEnabled @flag)}}
/>
<DToggleSwitch
@state={{this.enabled}}
class="admin-flag-item__toggle {{@flag.name_key}}"
{{on "click" (fn this.toggleFlagEnabled @flag)}}
/>
</td>
<td>
<div class="admin-flag-item__options admin-table-row-controls">
<DButton
class="btn btn-secondary admin-flag-item__edit"
class="btn-small admin-flag-item__edit"
@action={{this.edit}}
@label="admin.config_areas.flags.edit"
@disabled={{not this.canEdit}}

View File

@ -1,3 +1,4 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
@ -10,43 +11,62 @@ import {
PrimaryButton,
} from "admin/components/admin-page-action-button";
const AdminPageHeader = <template>
<div class="admin-page-header">
<div class="admin-page-header__breadcrumbs">
<DBreadcrumbsContainer />
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
{{yield to="breadcrumbs"}}
</div>
export default class AdminPageHeader extends Component {
get title() {
if (this.args.titleLabelTranslated) {
return this.args.titleLabelTranslated;
} else if (this.args.titleLabel) {
return i18n(this.args.titleLabel);
}
}
<div class="admin-page-header__title-row">
{{#if @titleLabel}}
<h1 class="admin-page-header__title">{{i18n @titleLabel}}</h1>
{{/if}}
<div class="admin-page-header__actions">
{{yield
(hash Primary=PrimaryButton Default=DefaultButton Danger=DangerButton)
to="actions"
}}
get description() {
if (this.args.descriptionLabelTranslated) {
return this.args.descriptionLabelTranslated;
} else if (this.args.descriptionLabel) {
return i18n(this.args.descriptionLabel);
}
}
<template>
<div class="admin-page-header">
<div class="admin-page-header__breadcrumbs">
<DBreadcrumbsContainer />
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
{{yield to="breadcrumbs"}}
</div>
</div>
{{#if @descriptionLabel}}
<p class="admin-page-header__description">
{{i18n @descriptionLabel}}
{{#if @learnMoreUrl}}
{{htmlSafe (i18n "learn_more_with_link" url=@learnMoreUrl)}}
<div class="admin-page-header__title-row">
{{#if this.title}}
<h1 class="admin-page-header__title">{{this.title}}</h1>
{{/if}}
</p>
{{/if}}
{{#unless @hideTabs}}
<div class="admin-nav-submenu">
<HorizontalOverflowNav class="admin-nav-submenu__tabs">
{{yield to="tabs"}}
</HorizontalOverflowNav>
<div class="admin-page-header__actions">
{{yield
(hash
Primary=PrimaryButton Default=DefaultButton Danger=DangerButton
)
to="actions"
}}
</div>
</div>
{{/unless}}
</div>
</template>;
export default AdminPageHeader;
{{#if this.description}}
<p class="admin-page-header__description">
{{htmlSafe this.description}}
{{#if @learnMoreUrl}}
{{htmlSafe (i18n "learn_more_with_link" url=@learnMoreUrl)}}
{{/if}}
</p>
{{/if}}
{{#unless @hideTabs}}
<div class="admin-nav-submenu">
<HorizontalOverflowNav class="admin-nav-submenu__tabs">
{{yield to="tabs"}}
</HorizontalOverflowNav>
</div>
{{/unless}}
</div>
</template>
}

View File

@ -1,3 +1,4 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import i18n from "discourse-common/helpers/i18n";
@ -7,27 +8,45 @@ import {
PrimaryButton,
} from "admin/components/admin-page-action-button";
const AdminPageSubheader = <template>
<div class="admin-page-subheader">
<div class="admin-page-subheader__title-row">
<h3 class="admin-page-subheader__title">{{i18n @titleLabel}}</h3>
<div class="admin-page-subheader__actions">
{{yield
(hash Primary=PrimaryButton Default=DefaultButton Danger=DangerButton)
to="actions"
}}
export default class AdminPageSubheader extends Component {
get title() {
if (this.args.titleLabelTranslated) {
return this.args.titleLabelTranslated;
} else if (this.args.titleLabel) {
return i18n(this.args.titleLabel);
}
}
get description() {
if (this.args.descriptionLabelTranslated) {
return this.args.descriptionLabelTranslated;
} else if (this.args.descriptionLabel) {
return i18n(this.args.descriptionLabel);
}
}
<template>
<div class="admin-page-subheader">
<div class="admin-page-subheader__title-row">
<h3 class="admin-page-subheader__title">{{this.title}}</h3>
<div class="admin-page-subheader__actions">
{{yield
(hash
Primary=PrimaryButton Default=DefaultButton Danger=DangerButton
)
to="actions"
}}
</div>
</div>
{{#if @descriptionLabel}}
<p class="admin-page-header__description">
{{i18n @descriptionLabel}}
{{#if this.description}}
<p class="admin-page-subheader__description">
{{htmlSafe this.description}}
{{#if @learnMoreUrl}}
{{htmlSafe (i18n "learn_more_with_link" url=@learnMoreUrl)}}
{{/if}}
</p>
{{/if}}
</div>
</div>
</template>;
export default AdminPageSubheader;
</template>
}

View File

@ -1,22 +0,0 @@
import i18n from "discourse-common/helpers/i18n";
const AdminPluginConfigMetadata = <template>
<div class="admin-plugin-config-page__metadata">
<div class="admin-plugin-config-area__metadata-title">
<h1>
{{@plugin.nameTitleized}}
</h1>
<p>
{{@plugin.about}}
{{#if @plugin.linkUrl}}
|
<a href={{@plugin.linkUrl}} rel="noopener noreferrer" target="_blank">
{{i18n "admin.plugins.learn_more"}}
</a>
{{/if}}
</p>
</div>
</div>
</template>;
export default AdminPluginConfigMetadata;

View File

@ -1,11 +1,12 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { service } from "@ember/service";
import DBreadcrumbsContainer from "discourse/components/d-breadcrumbs-container";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import NavItem from "discourse/components/nav-item";
import PluginOutlet from "discourse/components/plugin-outlet";
import i18n from "discourse-common/helpers/i18n";
import AdminPageHeader from "./admin-page-header";
import AdminPluginConfigArea from "./admin-plugin-config-area";
import AdminPluginConfigMetadata from "./admin-plugin-config-metadata";
import AdminPluginConfigTopNav from "./admin-plugin-config-top-nav";
export default class AdminPluginConfigPage extends Component {
@service currentUser;
@ -23,25 +24,56 @@ export default class AdminPluginConfigPage extends Component {
return classes.join(" ");
}
linkText(navLink) {
if (navLink.label) {
return i18n(navLink.label);
} else {
return navLink.text;
}
}
<template>
<div class="admin-plugin-config-page">
<DBreadcrumbsContainer />
<AdminPageHeader
@titleLabelTranslated={{@plugin.nameTitleized}}
@descriptionLabelTranslated={{@plugin.about}}
@learnMoreUrl={{@plugin.linkUrl}}
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/plugins"
@label={{i18n "admin.plugins.title"}}
/>
<DBreadcrumbsItem
@path="/admin/plugins/{{@plugin.name}}"
@label={{@plugin.nameTitleized}}
/>
<AdminPluginConfigMetadata @plugin={{@plugin}} />
{{#if this.adminPluginNavManager.isTopMode}}
<AdminPluginConfigTopNav />
{{/if}}
<DBreadcrumbsItem
@path="/admin/plugins"
@label={{i18n "admin.plugins.title"}}
/>
<DBreadcrumbsItem
@path="/admin/plugins/{{@plugin.name}}"
@label={{@plugin.nameTitleized}}
/>
</:breadcrumbs>
<:tabs>
{{#if this.adminPluginNavManager.isTopMode}}
{{#each
this.adminPluginNavManager.currentConfigNav.links
as |navLink|
}}
<NavItem
@route={{navLink.route}}
@i18nLabel={{this.linkText navLink}}
title={{this.linkText navLink}}
class="admin-plugin-config-page__top-nav-item"
>
{{this.linkText navLink}}
</NavItem>
{{/each}}
{{/if}}
</:tabs>
<:actions as |actions|>
<PluginOutlet
@name="admin-plugin-config-page-actions"
@outletArgs={{hash plugin=@plugin actions=actions}}
/>
</:actions>
</AdminPageHeader>
<div class="admin-plugin-config-page__content">
<div class={{this.mainAreaClasses}}>

View File

@ -1,36 +0,0 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import HorizontalOverflowNav from "discourse/components/horizontal-overflow-nav";
import NavItem from "discourse/components/nav-item";
import i18n from "discourse-common/helpers/i18n";
export default class AdminPluginConfigTopNav extends Component {
@service adminPluginNavManager;
linkText(navLink) {
if (navLink.label) {
return i18n(navLink.label);
} else {
return navLink.text;
}
}
<template>
<div class="admin-nav-submenu">
<HorizontalOverflowNav
class="plugin-nav admin-plugin-config-page__top-nav"
>
{{#each this.adminPluginNavManager.currentConfigNav.links as |navLink|}}
<NavItem
@route={{navLink.route}}
@i18nLabel={{this.linkText navLink}}
title={{this.linkText navLink}}
class="admin-plugin-config-page__top-nav-item"
>
{{this.linkText navLink}}
</NavItem>
{{/each}}
</HorizontalOverflowNav>
</div>
</template>
}

View File

@ -1,8 +1,9 @@
import Route from "@ember/routing/route";
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";
import SiteSetting from "admin/models/site-setting";
export default class AdminPluginsShowSettingsRoute extends Route {
export default class AdminPluginsShowSettingsRoute extends DiscourseRoute {
@service router;
queryParams = {
@ -17,4 +18,8 @@ export default class AdminPluginsShowSettingsRoute extends Route {
initialFilter: params.filter,
};
}
titleToken() {
return I18n.t("admin.plugins.change_settings_short");
}
}

View File

@ -1,10 +1,10 @@
import Route from "@ember/routing/route";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { sanitize } from "discourse/lib/text";
import DiscourseRoute from "discourse/routes/discourse";
import AdminPlugin from "admin/models/admin-plugin";
export default class AdminPluginsShowRoute extends Route {
export default class AdminPluginsShowRoute extends DiscourseRoute {
@service router;
@service adminPluginNavManager;
@ -21,4 +21,8 @@ export default class AdminPluginsShowRoute extends Route {
deactivate() {
this.adminPluginNavManager.currentPlugin = null;
}
titleToken() {
return this.adminPluginNavManager.currentPlugin.nameTitleized;
}
}

View File

@ -1,4 +1,4 @@
import { inject as service } from "@ember/service";
import { service } from "@ember/service";
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";

View File

@ -13,7 +13,7 @@ export default class FKSubmit extends Component {
@forwardEvent="true"
class="btn-primary form-kit__button"
type="submit"
isLoading={{@isSubmitting}}
@isLoading={{@isLoading}}
...attributes
/>
</template>

View File

@ -38,4 +38,14 @@ module("Integration | Component | FormKit | Layout | Submit", function (hooks) {
.dom(".form-kit__button")
.hasText(I18n.t("cancel"), "it allows to override the label");
});
test("@isLoading", async function (assert) {
await render(<template>
<Form as |form|>
<form.Submit @label="cancel" @isLoading={{true}} />
</Form>
</template>);
assert.dom(".form-kit__button .d-icon-spinner").exists();
});
});

View File

@ -142,6 +142,18 @@ $mobile-breakpoint: 700px;
max-width: 100px;
}
}
.admin-table-row-controls {
text-align: right;
display: flex;
flex-direction: row;
gap: 0.5em;
justify-content: flex-end;
.fk-d-menu__trigger {
font-size: var(--font-down-1);
}
}
}
.admin-contents table.grid {

View File

@ -7,24 +7,12 @@
&__description {
margin-top: 0.5em;
}
&__options {
display: flex;
align-items: center;
justify-content: space-between;
}
.d-toggle-switch__label {
margin-bottom: 0;
}
.d-toggle-switch {
margin-right: 2em;
}
.btn-secondary {
padding: 0.25em 0.325em;
margin-right: 0.75em;
}
.flag-menu-trigger {
padding: 0.25em 0.325em;
}
&__delete.btn,
&__delete.btn:hover {

View File

@ -0,0 +1,85 @@
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 replaceEmoji from "discourse/helpers/replace-emoji";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import ChannelTitle from "discourse/plugins/chat/discourse/components/channel-title";
export default class AdminChatIncomingWebhooksList extends Component {
@service dialog;
@tracked loading = false;
get sortedWebhooks() {
return this.args.webhooks?.sortBy("updated_at").reverse() || [];
}
@action
destroyWebhook(webhook) {
this.dialog.deleteConfirm({
message: I18n.t("chat.incoming_webhooks.confirm_destroy"),
didConfirm: async () => {
this.loading = true;
try {
await ajax(`/admin/plugins/chat/hooks/${webhook.id}`, {
type: "DELETE",
});
this.args.webhooks.removeObject(webhook);
} catch (err) {
popupAjaxError(err);
} finally {
this.loading = false;
}
},
});
}
<template>
<table>
<thead>
<th>{{i18n "chat.incoming_webhooks.name"}}</th>
<th>{{i18n "chat.incoming_webhooks.emoji"}}</th>
<th>{{i18n "chat.incoming_webhooks.username"}}</th>
<th>{{i18n "chat.incoming_webhooks.description"}}</th>
<th>{{i18n "chat.incoming_webhooks.channel"}}</th>
<th></th>
</thead>
<tbody>
{{#each this.sortedWebhooks as |webhook|}}
<tr class="incoming-chat-webhooks-row" data-webhook-id={{webhook.id}}>
<td>{{webhook.name}}</td>
<td>{{replaceEmoji webhook.emoji}}</td>
<td>{{webhook.username}}</td>
<td>{{webhook.description}}</td>
<td><ChannelTitle @channel={{webhook.chat_channel}} /></td>
<td
class="incoming-chat-webhooks-row__controls admin-table-row-controls"
>
<LinkTo
@route="adminPlugins.show.discourse-chat-incoming-webhooks.show"
@model={{webhook.id}}
class="btn btn-small admin-chat-incoming-webhooks-edit"
>{{i18n "chat.incoming_webhooks.edit"}}</LinkTo>
<DButton
@icon="trash-alt"
@title="chat.incoming_webhooks.delete"
@action={{fn this.destroyWebhook webhook}}
class="btn-danger btn-small admin-chat-incoming-webhooks-delete"
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</template>
}

View File

@ -1,14 +1,20 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
export default class ChatAdminExportMessages extends Component {
@service chatAdminApi;
export default class ChatAdminPluginActions extends Component {
@service dialog;
@service chatAdminApi;
@action
confirmExportMessages() {
return this.dialog.confirm({
message: I18n.t("chat.admin.export_messages.confirm_export"),
didConfirm: () => this.exportMessages(),
});
}
@action
async exportMessages() {
@ -23,15 +29,11 @@ export default class ChatAdminExportMessages extends Component {
}
<template>
<section class="admin-section">
<h3>{{i18n "chat.admin.export_messages.title"}}</h3>
<p>{{i18n "chat.admin.export_messages.description"}}</p>
<DButton
@label="chat.admin.export_messages.create_export"
@title="chat.admin.export_messages.create_export"
@action={{this.exportMessages}}
class="btn-primary"
/>
</section>
<@outletArgs.actions.Primary
@label="chat.admin.export_messages.create_export"
@title="chat.admin.export_messages.create_export"
@action={{this.confirmExportMessages}}
class="admin-chat-export"
/>
</template>
}

View File

@ -0,0 +1,187 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { not } from "truth-helpers";
import DButton from "discourse/components/d-button";
import EmojiPicker from "discourse/components/emoji-picker";
import Form from "discourse/components/form";
import replaceEmoji from "discourse/helpers/replace-emoji";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import ChatChannelChooser from "discourse/plugins/chat/discourse/components/chat-channel-chooser";
export default class ChatIncomingWebhookEditForm extends Component {
@service toasts;
@service router;
@tracked emojiPickerIsActive = false;
get formData() {
return {
name: this.args.webhook?.name,
description: this.args.webhook?.description,
username: this.args.webhook?.username,
chat_channel_id: this.args.webhook?.chat_channel.id,
emoji: this.args.webhook?.emoji,
};
}
@action
emojiSelected(setData, emoji) {
setData("emoji", `:${emoji}:`);
this.emojiPickerIsActive = false;
}
@action
resetEmoji(setData) {
setData("emoji", null);
}
@action
async save(data) {
try {
if (this.args.webhook?.id) {
await ajax(`/admin/plugins/chat/hooks/${this.args.webhook.id}`, {
data,
type: "PUT",
});
this.toasts.success({
duration: 3000,
data: {
message: I18n.t("chat.incoming_webhooks.saved"),
},
});
} else {
const webhook = await ajax(`/admin/plugins/chat/hooks`, {
data,
type: "POST",
});
this.toasts.success({
duration: 3000,
data: {
message: I18n.t("chat.incoming_webhooks.created"),
},
});
this.router
.transitionTo(
"adminPlugins.show.discourse-chat-incoming-webhooks.show",
webhook
)
.then(() => {
this.router.refresh();
});
}
} catch (err) {
popupAjaxError(err);
}
}
<template>
<Form @data={{this.formData}} @onSubmit={{this.save}} as |form|>
<form.Field
@name="name"
@title={{i18n "chat.incoming_webhooks.name"}}
@validation="required"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="description"
@title={{i18n "chat.incoming_webhooks.description"}}
as |field|
>
<field.Textarea />
</form.Field>
<form.Field
@name="username"
@title={{i18n "chat.incoming_webhooks.username"}}
@description={{i18n "chat.incoming_webhooks.username_instructions"}}
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="chat_channel_id"
@title={{i18n "chat.incoming_webhooks.post_to"}}
@validation="required"
as |field|
>
<field.Custom>
<ChatChannelChooser
@content={{@chatChannels}}
@value={{field.value}}
@onChange={{field.set}}
/>
</field.Custom>
</form.Field>
<form.Field
@name="emoji"
@title={{i18n "chat.incoming_webhooks.emoji"}}
@description={{i18n "chat.incoming_webhooks.emoji_instructions"}}
as |field|
>
<field.Custom>
{{#if field.value}}
{{i18n "chat.incoming_webhooks.current_emoji"}}
<span class="incoming-chat-webhooks-current-emoji">
{{replaceEmoji field.value}}
</span>
{{/if}}
<EmojiPicker
@isActive={{this.emojiPickerIsActive}}
@isEditorFocused={{true}}
@emojiSelected={{fn this.emojiSelected form.set}}
@onEmojiPickerClose={{fn (mut this.emojiPickerIsActive) false}}
/>
{{#unless this.emojiPickerIsActive}}
<form.Row as |row|>
<row.Col @size={{6}}>
<DButton
@label="chat.incoming_webhooks.select_emoji"
@action={{fn (mut this.emojiPickerIsActive) true}}
class="btn-primary admin-chat-webhooks-select-emoji"
/>
</row.Col>
<row.Col @size={{6}}>
<DButton
@label="chat.incoming_webhooks.reset_emoji"
@action={{fn this.resetEmoji form.set}}
@disabled={{not field.value}}
class="admin-chat-webhooks-clear-emoji"
/>
</row.Col>
</form.Row>
{{/unless}}
</field.Custom>
</form.Field>
{{#if @webhook.url}}
<form.Container
@name="url"
@title={{i18n "chat.incoming_webhooks.url"}}
@subtitle={{i18n "chat.incoming_webhooks.url_instructions"}}
>
<code>{{@webhook.url}}</code>
</form.Container>
{{/if}}
<form.Submit />
</Form>
</template>
}

View File

@ -0,0 +1,40 @@
import EmberObject from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "discourse-i18n";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default class DiscourseChatIncomingWebhooksIndex extends DiscourseRoute {
@service currentUser;
async model() {
if (!this.currentUser?.admin) {
return { model: null };
}
try {
const model = await ajax("/admin/plugins/chat/hooks.json");
model.chat_channels = model.chat_channels.map((channel) =>
ChatChannel.create(channel)
);
model.incoming_chat_webhooks = model.incoming_chat_webhooks.map(
(webhook) => {
webhook.chat_channel = ChatChannel.create(webhook.chat_channel);
return EmberObject.create(webhook);
}
);
return model;
} catch (err) {
popupAjaxError(err);
}
}
titleToken() {
return I18n.t("chat.incoming_webhooks.title");
}
}

View File

@ -0,0 +1,33 @@
import EmberObject from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import DiscourseRoute from "discourse/routes/discourse";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default class DiscourseChatIncomingWebhooksNew extends DiscourseRoute {
@service adminPluginNavManager;
@service currentUser;
async model() {
if (!this.currentUser?.admin) {
return { model: null };
}
try {
const model = await ajax("/admin/plugins/chat/hooks/new.json");
model.webhook = EmberObject.create(model.webhook);
model.webhook.chat_channel = ChatChannel.create(
model.webhook.chat_channel
);
model.chat_channels = model.chat_channels.map((channel) =>
ChatChannel.create(channel)
);
return model;
} catch (err) {
popupAjaxError(err);
}
}
}

View File

@ -0,0 +1,32 @@
import EmberObject from "@ember/object";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import DiscourseRoute from "discourse/routes/discourse";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default class DiscourseChatIncomingWebhooksShow extends DiscourseRoute {
@service currentUser;
async model(params) {
if (!this.currentUser?.admin) {
return { model: null };
}
try {
const model = await ajax(`/admin/plugins/chat/hooks/${params.id}.json`);
model.webhook = EmberObject.create(model.webhook);
model.webhook.chat_channel = ChatChannel.create(
model.webhook.chat_channel
);
model.chat_channels = model.chat_channels.map((channel) =>
ChatChannel.create(channel)
);
return model;
} catch (err) {
popupAjaxError(err);
}
}
}

View File

@ -0,0 +1,31 @@
<DBreadcrumbsItem
@path="/admin/plugins/chat/hooks"
@label={{i18n "chat.incoming_webhooks.title"}}
/>
<div class="discourse-chat-incoming-webhooks admin-detail">
<AdminPageSubheader
@titleLabel="chat.incoming_webhooks.title"
@descriptionLabel="chat.incoming_webhooks.instructions"
>
<:actions as |actions|>
<actions.Primary
@label="chat.incoming_webhooks.new"
@title="chat.incoming_webhooks.new"
@route="adminPlugins.show.discourse-chat-incoming-webhooks.new"
@routeModels="chat"
class="admin-incoming-webhooks-new"
/>
</:actions>
</AdminPageSubheader>
<div class="incoming-chat-webhooks">
{{#if this.model.incoming_chat_webhooks}}
<AdminChatIncomingWebhooksList
@webhooks={{this.model.incoming_chat_webhooks}}
/>
{{else}}
{{i18n "chat.incoming_webhooks.none"}}
{{/if}}
</div>
</div>

View File

@ -0,0 +1,9 @@
<div class="admin-detail discourse-chat-incoming-webhooks">
<BackButton
@label="chat.incoming_webhooks.back"
@route="adminPlugins.show.discourse-chat-incoming-webhooks.index"
class="incoming-chat-webhooks-back"
/>
<ChatIncomingWebhookEditForm @chatChannels={{model.chat_channels}} />
</div>

View File

@ -0,0 +1,14 @@
<div class="admin-detail discourse-chat-incoming-webhooks">
<BackButton
@label="chat.incoming_webhooks.back"
@route="adminPlugins.show.discourse-chat-incoming-webhooks.index"
class="incoming-chat-webhooks-back"
/>
<ConditionalLoadingSpinner @condition={{not model.webhook}}>
<ChatIncomingWebhookEditForm
@webhook={{model.webhook}}
@chatChannels={{model.chat_channels}}
/>
</ConditionalLoadingSpinner>
</div>

View File

@ -16,6 +16,25 @@ module Chat
)
end
def show
webhook =
Chat::IncomingWebhook.includes(:chat_channel).find(params[:incoming_chat_webhook_id])
render_serialized(
{ chat_channels: Chat::Channel.public_channels, webhook: webhook },
Chat::AdminChatWebhookShowSerializer,
root: false,
)
end
def new
serialized_channels =
Chat::Channel.public_channels.map do |channel|
Chat::ChannelSerializer.new(channel, scope: Guardian.new(current_user))
end
render json: serialized_channels, root: "chat_channels"
end
def create
params.require(%i[name chat_channel_id])

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Chat
class AdminChatWebhookShowSerializer < ApplicationSerializer
has_many :chat_channels, serializer: Chat::ChannelSerializer, embed: :objects
has_one :webhook, serializer: Chat::IncomingWebhookSerializer, embed: :objects
def chat_channels
object[:chat_channels]
end
def webhook
object[:webhook]
end
end
end

View File

@ -1,7 +1,14 @@
export default {
resource: "admin.adminPlugins",
resource: "admin.adminPlugins.show",
path: "/plugins",
map() {
this.route("chat");
this.route(
"discourse-chat-incoming-webhooks",
{ path: "hooks" },
function () {
this.route("new");
this.route("show", { path: "/:id" });
}
);
},
};

View File

@ -1,139 +0,0 @@
import Controller from "@ember/controller";
import EmberObject, { action, computed } from "@ember/object";
import { and } from "@ember/object/computed";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "discourse-i18n";
export default class AdminPluginsChatController extends Controller {
@service dialog;
queryParams = [
{
selectedWebhookId: "id",
},
];
loading = false;
creatingNew = false;
newWebhookName = "";
newWebhookChannelId = null;
emojiPickerIsActive = false;
@and("newWebhookName", "newWebhookChannelId") nameAndChannelValid;
@computed("model.incoming_chat_webhooks.@each.updated_at")
get sortedWebhooks() {
return (
this.model.incoming_chat_webhooks?.sortBy("updated_at").reverse() || []
);
}
@computed("selectedWebhookId")
get selectedWebhook() {
if (!this.selectedWebhookId) {
return;
}
const id = parseInt(this.selectedWebhookId, 10);
return this.model.incoming_chat_webhooks.findBy("id", id);
}
@computed("selectedWebhook.name", "selectedWebhook.chat_channel.id")
get saveEditDisabled() {
return !this.selectedWebhook.name || !this.selectedWebhook.chat_channel.id;
}
@action
createNewWebhook() {
if (this.loading) {
return;
}
this.set("loading", true);
const data = {
name: this.newWebhookName,
chat_channel_id: this.newWebhookChannelId,
};
return ajax("/admin/plugins/chat/hooks", { data, type: "POST" })
.then((webhook) => {
const newWebhook = EmberObject.create(webhook);
this.set(
"model.incoming_chat_webhooks",
[newWebhook].concat(this.model.incoming_chat_webhooks)
);
this.resetNewWebhook();
this.setProperties({
loading: false,
selectedWebhookId: newWebhook.id,
});
})
.catch(popupAjaxError);
}
@action
resetNewWebhook() {
this.setProperties({
creatingNew: false,
newWebhookName: "",
newWebhookChannelId: null,
});
}
@action
destroyWebhook(webhook) {
this.dialog.deleteConfirm({
message: I18n.t("chat.incoming_webhooks.confirm_destroy"),
didConfirm: () => {
this.set("loading", true);
return ajax(`/admin/plugins/chat/hooks/${webhook.id}`, {
type: "DELETE",
})
.then(() => {
this.model.incoming_chat_webhooks.removeObject(webhook);
this.set("loading", false);
})
.catch(popupAjaxError);
},
});
}
@action
emojiSelected(emoji) {
this.selectedWebhook.set("emoji", `:${emoji}:`);
return this.set("emojiPickerIsActive", false);
}
@action
saveEdit() {
this.set("loading", true);
const data = {
name: this.selectedWebhook.name,
chat_channel_id: this.selectedWebhook.chat_channel.id,
description: this.selectedWebhook.description,
emoji: this.selectedWebhook.emoji,
username: this.selectedWebhook.username,
};
return ajax(`/admin/plugins/chat/hooks/${this.selectedWebhook.id}`, {
data,
type: "PUT",
})
.then(() => {
this.selectedWebhook.set("updated_at", new Date());
this.setProperties({
loading: false,
selectedWebhookId: null,
});
})
.catch(popupAjaxError);
}
@action
changeChatChannel(chatChannelId) {
this.selectedWebhook.set(
"chat_channel",
this.model.chat_channels.findBy("id", chatChannelId)
);
}
}

View File

@ -0,0 +1,28 @@
import { PLUGIN_NAV_MODE_TOP } from "discourse/lib/admin-plugin-config-nav";
import { withPluginApi } from "discourse/lib/plugin-api";
import ChatAdminPluginActions from "discourse/plugins/chat/admin/components/chat-admin-plugin-actions";
export default {
name: "discourse-chat-admin-plugin-configuration-nav",
initialize(container) {
const currentUser = container.lookup("service:current-user");
if (!currentUser?.admin) {
return;
}
withPluginApi("1.1.0", (api) => {
api.addAdminPluginConfigurationNav("chat", PLUGIN_NAV_MODE_TOP, [
{
label: "chat.incoming_webhooks.title",
route: "adminPlugins.show.discourse-chat-incoming-webhooks",
},
]);
api.renderInOutlet(
"admin-plugin-config-page-actions",
ChatAdminPluginActions
);
});
},
};

View File

@ -1,24 +0,0 @@
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse";
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
export default class AdminPluginsChatRoute extends DiscourseRoute {
model() {
if (!this.currentUser?.admin) {
return { model: null };
}
return ajax("/admin/plugins/chat/hooks.json").then((model) => {
model.incoming_chat_webhooks = model.incoming_chat_webhooks.map(
(webhook) => EmberObject.create(webhook)
);
model.chat_channels = model.chat_channels.map((channel) => {
return ChatChannel.create(channel);
});
return model;
});
}
}

View File

@ -1,196 +0,0 @@
<Chat::Admin::ExportMessages />
{{#if this.selectedWebhook}}
<DButton
@icon="chevron-left"
@label="chat.incoming_webhooks.back"
@title="chat.incoming_webhooks.back"
@action={{fn (mut this.selectedWebhookId) null}}
class="incoming-chat-webhooks-back"
/>
<form class="form-vertical">
<div class="control-group">
<label class="control-label">
{{i18n "chat.incoming_webhooks.name"}}
</label>
<Input
@type="text"
@value={{this.selectedWebhook.name}}
placeholder={{i18n "chat.incoming_webhooks.name"}}
/>
</div>
<div class="control-group">
<label class="control-label">
{{i18n "chat.incoming_webhooks.description"}}
</label>
<Textarea @value={{this.selectedWebhook.description}} />
</div>
<div class="control-group">
<label class="control-label">
{{i18n "chat.incoming_webhooks.username"}}
</label>
<Input
@type="text"
@value={{this.selectedWebhook.username}}
placeholder={{i18n "chat.incoming_webhooks.system"}}
/>
<div class="control-instructions">
{{i18n "chat.incoming_webhooks.username_instructions"}}
</div>
</div>
<div class="control-group">
<label class="control-label">
{{i18n "chat.incoming_webhooks.post_to"}}
</label>
<ChatChannelChooser
@content={{this.model.chat_channels}}
@value={{this.selectedWebhook.chat_channel.id}}
@onChange={{action "changeChatChannel"}}
/>
</div>
<div class="control-group">
<label class="control-label">
{{#if this.selectedWebhook.emoji}}
{{i18n "chat.incoming_webhooks.current_emoji"}}
<span class="incoming-chat-webhooks-current-emoji">
{{replace-emoji this.selectedWebhook.emoji}}
</span>
{{else}}
{{i18n "chat.incoming_webhooks.no_emoji"}}
{{/if}}
</label>
<EmojiPicker
@isActive={{this.emojiPickerIsActive}}
@isEditorFocused={{true}}
@emojiSelected={{action "emojiSelected"}}
@onEmojiPickerClose={{fn (mut this.emojiPickerIsActive) false}}
/>
{{#unless this.emojiPickerIsActive}}
<DButton
@label="chat.incoming_webhooks.select_emoji"
@action={{fn (mut this.emojiPickerIsActive) true}}
class="btn-primary"
/>
<DButton
@label="chat.incoming_webhooks.reset_emoji"
@action={{fn (mut this.selectedWebhook.emoji) null}}
@disabled={{not this.selectedWebhook.emoji}}
/>
{{/unless}}
<div class="control-instructions">
{{i18n "chat.incoming_webhooks.emoji_instructions"}}
</div>
</div>
<div class="control-group">
<label class="control-label">{{i18n "chat.incoming_webhooks.url"}}</label>
<label>{{this.selectedWebhook.url}}</label>
<div class="control-instructions">
{{i18n "chat.incoming_webhooks.url_instructions"}}
</div>
</div>
<DButton
@label="chat.incoming_webhooks.save"
@title="chat.incoming_webhooks.save"
@action={{this.saveEdit}}
@disabled={{this.saveEditDisabled}}
class="btn-primary"
/>
</form>
{{else}}
{{! Index view }}
<h3>{{i18n "chat.incoming_webhooks.title"}}</h3>
{{#if this.creatingNew}}
<div class="new-incoming-webhook-container">
<Input
@type="text"
@value={{this.newWebhookName}}
placeholder={{i18n "chat.incoming_webhooks.name_placeholder"}}
/>
<ChatChannelChooser
@content={{this.model.chat_channels}}
@value={{this.newWebhookChannelId}}
@onChange={{fn (mut this.newWebhookChannelId)}}
/>
<DButton
@label="chat.create"
@title="chat.create"
@disabled={{not this.nameAndChannelValid}}
@action={{this.createNewWebhook}}
class="btn-primary create-new-incoming-webhook-btn"
/>
<DButton
@label="chat.cancel"
@title="chat.cancel"
@action={{this.resetNewWebhook}}
/>
</div>
{{else}}
<DButton
@label="chat.incoming_webhooks.new"
@title="chat.incoming_webhooks.new"
@action={{fn (mut this.creatingNew) true}}
class="btn-primary"
/>
{{/if}}
<p>{{html-safe (i18n "chat.incoming_webhooks.instructions")}}</p>
<div class="incoming-chat-webhooks">
{{#if this.model.incoming_chat_webhooks}}
{{#each this.sortedWebhooks as |webhook|}}
<div class="incoming-chat-webhooks--row">
<div class="incoming-chat-webhooks--row--details">
<div class="incoming-chat-webhooks--row--details--name">
{{webhook.name}}
</div>
<div>
{{#if webhook.emoji}}
{{replace-emoji webhook.emoji}}
{{/if}}
{{#if webhook.username}}
{{webhook.username}}
{{else}}
{{i18n "chat.incoming_webhooks.system"}}
{{/if}}
</div>
<div><ChannelTitle @channel={{webhook.chat_channel}} /></div>
<div>{{webhook.description}}</div>
</div>
<div class="incoming-chat-webhooks--row--controls">
<div>
<DButton
@icon="pencil-alt"
@label="chat.incoming_webhooks.edit"
@action={{fn (mut this.selectedWebhookId) webhook.id}}
/>
<DButton
@icon="trash-alt"
@title="chat.incoming_webhooks.delete"
@action={{fn this.destroyWebhook webhook}}
class="btn-danger"
/>
</div>
</div>
</div>
{{/each}}
{{else}}
{{i18n "chat.incoming_webhooks.none"}}
{{/if}}
</div>
{{/if}}

View File

@ -506,6 +506,7 @@ en:
title: "Export chat messages"
description: "This exports all messages from all channels."
create_export: "Create export"
confirm_export: "This exports all messages from all channels and sends the result to you in a personal message. Are you sure you want to export?"
export_has_started: "The export has started. You'll receive a PM when it's ready."
my_threads:
@ -528,6 +529,7 @@ en:
incoming_webhooks:
back: "Back"
channel_placeholder: "Select a channel"
channel: "Channel"
confirm_destroy: "Are you sure you want to delete this incoming webhook? This cannot be un-done."
current_emoji: "Current Emoji"
description: "Description"
@ -551,6 +553,8 @@ en:
username: "Username"
username_instructions: "Username of bot that posts to channel. Defaults to 'system' when left blank."
instructions: "Incoming webhooks can be used by external systems to post messages into a designated chat channel as a bot user via the <code>/hooks/:key</code> endpoint. The payload consists of a single <code>text</code> parameter, which is limited to 2000 characters.<br><br>We also support limited Slack-formatted <code>text</code> parameters, extracting links and mentions based on the format at <a href=\"https://api.slack.com/reference/surfaces/formatting\">https://api.slack.com/reference/surfaces/formatting</a>, but the <code>/hooks/:key/slack</code> endpoint must be used for this."
saved: "Webhook changes saved"
created: "Webhook created"
selection:
cancel: "Cancel"

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
# name: chat
# about: Adds chat functionality.
# about: Adds chat functionality to your site so it can natively support both long-form and short-form communication needs of your online community.
# meta_topic_id: 230881
# version: 0.4
# authors: Kane York, Mark VanLandingham, Martin Brennan, Joffrey Jaffeux
@ -27,7 +27,7 @@ register_svg_icon "file-image"
register_svg_icon "stop-circle"
# route: /admin/plugins/chat
add_admin_route "chat.admin.title", "chat"
add_admin_route "chat.admin.title", "chat", use_new_show_route: true
GlobalSetting.add_default(:allow_unsecure_chat_uploads, false)
@ -443,6 +443,11 @@ after_initialize do
put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" =>
"chat/admin/incoming_webhooks#update",
:constraints => StaffConstraint.new
get "/admin/plugins/chat/hooks/new" => "chat/admin/incoming_webhooks#new",
:constraints => StaffConstraint.new
get "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" =>
"chat/admin/incoming_webhooks#show",
:constraints => StaffConstraint.new
delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" =>
"chat/admin/incoming_webhooks#destroy",
:constraints => StaffConstraint.new

View File

@ -178,8 +178,8 @@ Fabricator(:chat_webhook_event, class_name: "Chat::WebhookEvent") do
end
Fabricator(:incoming_chat_webhook, class_name: "Chat::IncomingWebhook") do
name { sequence(:name) { |i| "#{i + 1}" } }
key { sequence(:key) { |i| "#{i + 1}" } }
name { sequence(:name) { |i| "Test webhook #{i + 1}" } }
emoji { %w[:joy: :rocket: :handshake:].sample }
chat_channel { Fabricate(:chat_channel, chatable: Fabricate(:category)) }
end

View File

@ -25,7 +25,7 @@ RSpec.describe ApplicationController do
"admin_route" => {
"label" => "chat.admin.title",
"location" => "chat",
"use_new_show_route" => false,
"use_new_show_route" => true,
},
"enabled" => true,
},

View File

@ -1,26 +1,32 @@
# frozen_string_literal: true
RSpec.describe "Chat CSV exports", type: :system do
fab!(:admin)
RSpec.describe "Admin Chat CSV exports", type: :system do
let(:dialog) { PageObjects::Components::Dialog.new }
let(:csv_export_pm_page) { PageObjects::Pages::CSVExportPM.new }
fab!(:current_user) { Fabricate(:admin) }
before do
Jobs.run_immediately!
sign_in(admin)
sign_in(current_user)
chat_system_bootstrap
end
xit "exports chat messages" do
it "exports chat messages" do
Jobs.run_immediately!
message_1 = Fabricate(:chat_message, created_at: 12.months.ago)
message_2 = Fabricate(:chat_message, created_at: 6.months.ago)
message_3 = Fabricate(:chat_message, created_at: 1.months.ago)
message_4 = Fabricate(:chat_message, created_at: Time.now)
visit "/admin/plugins/chat"
click_button "Create export"
click_button I18n.t("js.chat.admin.export_messages.create_export")
dialog.click_yes
visit "/u/#{admin.username}/messages"
click_link "[Chat Message] Data export complete"
visit "/u/#{current_user.username}/messages"
click_link I18n.t(
"system_messages.csv_export_succeeded.subject_template",
export_title: "Chat Message",
)
expect(csv_export_pm_page).to have_download_link
exported_data = csv_export_pm_page.download_and_extract

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
describe "Admin Chat Incoming Webhooks", type: :system do
fab!(:current_user) { Fabricate(:admin) }
fab!(:chat_channel_1) { Fabricate(:chat_channel) }
let(:dialog) { PageObjects::Components::Dialog.new }
let(:admin_incoming_webhooks_page) { PageObjects::Pages::AdminIncomingWebhooks.new }
before do
chat_system_bootstrap(current_user)
sign_in(current_user)
end
it "can create incoming webhooks" do
admin_incoming_webhooks_page.visit
admin_incoming_webhooks_page.click_new
admin_incoming_webhooks_page.form.field("name").fill_in("Test webhook")
admin_incoming_webhooks_page.form.field("description").fill_in("Some test content")
admin_incoming_webhooks_page.form.field("username").fill_in("system")
admin_incoming_webhooks_page.channel_chooser.expand
admin_incoming_webhooks_page.channel_chooser.select_row_by_value(chat_channel_1.id)
admin_incoming_webhooks_page.channel_chooser.collapse
# TODO (martin) Add an emoji selection once Joffrey's emoji selector
# unification has landed in core.
admin_incoming_webhooks_page.form.submit
expect(page).to have_content(I18n.t("js.chat.incoming_webhooks.created"))
expect(page).to have_content(Chat::IncomingWebhook.find_by(name: "Test webhook").url)
end
describe "existing webhooks" do
fab!(:webhook_1) { Fabricate(:incoming_chat_webhook) }
fab!(:webhook_2) { Fabricate(:incoming_chat_webhook) }
it "can list existing incoming webhooks" do
admin_incoming_webhooks_page.visit
expect(page).to have_content(webhook_1.name)
expect(page).to have_content(webhook_1.chat_channel.title)
expect(page).to have_content(webhook_2.name)
expect(page).to have_content(webhook_2.chat_channel.title)
end
it "can edit an existing incoming webhook" do
admin_incoming_webhooks_page.visit
admin_incoming_webhooks_page
.list_row(webhook_1.id)
.find(".admin-chat-incoming-webhooks-edit")
.click
expect(admin_incoming_webhooks_page.form.field("name").value).to eq(webhook_1.name)
admin_incoming_webhooks_page.form.field("name").fill_in("Wow so cool")
admin_incoming_webhooks_page.form.submit
expect(page).to have_content(I18n.t("js.chat.incoming_webhooks.saved"))
admin_incoming_webhooks_page.visit
expect(page).to have_content("Wow so cool")
end
it "can delete an existing incoming webhook" do
admin_incoming_webhooks_page.visit
admin_incoming_webhooks_page
.list_row(webhook_1.id)
.find(".admin-chat-incoming-webhooks-delete")
.click
dialog.click_danger
expect(page).not_to have_content(webhook_1.name)
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminIncomingWebhooks < PageObjects::Pages::Base
def visit
page.visit("/admin/plugins/chat/hooks")
self
end
def click_new
find(".admin-incoming-webhooks-new").click
end
def channel_chooser
PageObjects::Components::SelectKit.new(".chat-channel-chooser")
end
def form
PageObjects::Components::FormKit.new(".discourse-chat-incoming-webhooks .form-kit")
end
def list_row(webhook_id)
find(".incoming-chat-webhooks-row[data-webhook-id='#{webhook_id}']")
end
end
end
end