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:
parent
56877e9acf
commit
61c1d35f17
|
@ -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}}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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}}>
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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])
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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" });
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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}}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue