UX: admins embedding page follows admin ux guideline (#30122)

Conversion of /admin/customize/embedding page to follow admin UX guidelines.
This commit is contained in:
Krzysztof Kotlarek 2025-01-06 13:01:08 +11:00 committed by GitHub
parent 02113fc22a
commit 407fa69778
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 870 additions and 501 deletions

View File

@ -6,7 +6,7 @@ import icon from "discourse-common/helpers/d-icon";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
export default class AdminConfigAreaCard extends Component { export default class AdminConfigAreaCard extends Component {
@tracked collapsed = false; @tracked collapsed = this.args.collapsed;
get computedHeading() { get computedHeading() {
if (this.args.heading) { if (this.args.heading) {

View File

@ -0,0 +1,143 @@
import Component from "@glimmer/component";
import { inject as controller } from "@ember/controller";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import BackButton from "discourse/components/back-button";
import Form from "discourse/components/form";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import CategoryChooser from "select-kit/components/category-chooser";
import TagChooser from "select-kit/components/tag-chooser";
import UserChooser from "select-kit/components/user-chooser";
export default class AdminEmbeddingHostForm extends Component {
@service router;
@service site;
@service store;
@controller adminEmbedding;
get isEditing() {
return this.args.host;
}
get header() {
return this.isEditing
? "admin.embedding.host_form.edit_header"
: "admin.embedding.host_form.add_header";
}
get formData() {
if (!this.isEditing) {
return {};
}
return {
host: this.args.host.host,
allowed_paths: this.args.host.allowed_paths,
category: this.args.host.category_id,
tags: this.args.host.tags,
user: isEmpty(this.args.host.user) ? null : [this.args.host.user],
};
}
@action
async save(data) {
const host = this.args.host || this.store.createRecord("embeddable-host");
try {
await host.save({
...data,
user: data.user?.at(0),
category_id: data.category,
});
if (!this.isEditing) {
this.adminEmbedding.embedding.embeddable_hosts.push(host);
}
this.router.transitionTo("adminEmbedding");
} catch (error) {
popupAjaxError(error);
}
}
<template>
<BackButton @route="adminEmbedding" @label="admin.embedding.back" />
<div class="admin-config-area">
<div class="admin-config-area__primary-content admin-embedding-host-form">
<AdminConfigAreaCard @heading={{this.header}}>
<:content>
<Form @onSubmit={{this.save}} @data={{this.formData}} as |form|>
<form.Field
@name="host"
@title={{i18n "admin.embedding.host"}}
@validation="required"
@format="large"
as |field|
>
<field.Input placeholder="example.com" />
</form.Field>
<form.Field
@name="allowed_paths"
@title={{i18n "admin.embedding.allowed_paths"}}
@format="large"
as |field|
>
<field.Input placeholder="/blog/.*" />
</form.Field>
<form.Field
@name="category"
@title={{i18n "admin.embedding.category"}}
as |field|
>
<field.Custom>
<CategoryChooser
@value={{field.value}}
@onChange={{field.set}}
class="admin-embedding-host-form__category"
/>
</field.Custom>
</form.Field>
<form.Field
@name="tags"
@title={{i18n "admin.embedding.tags"}}
as |field|
>
<field.Custom>
<TagChooser
@tags={{field.value}}
@everyTag={{true}}
@excludeSynonyms={{true}}
@unlimitedTagCount={{true}}
@onChange={{field.set}}
@options={{hash
filterPlaceholder="category.tags_placeholder"
}}
class="admin-embedding-host-form__tags"
/>
</field.Custom>
</form.Field>
<form.Field
@name="user"
@title={{i18n "admin.embedding.post_author"}}
as |field|
>
<field.Custom>
<UserChooser
@value={{field.value}}
@onChange={{field.set}}
@options={{hash maximum=1 excludeCurrentUser=false}}
class="admin-embedding-host-form__post_author"
/>
</field.Custom>
</form.Field>
<form.Submit @label="admin.embedding.host_form.save" />
</Form>
</:content>
</AdminConfigAreaCard>
</div>
</div>
</template>
}

View File

@ -0,0 +1,76 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import categoryBadge from "discourse/helpers/category-badge";
import Category from "discourse/models/category";
import { i18n } from "discourse-i18n";
export default class EmbeddableHost extends Component {
@service dialog;
@tracked category = null;
@tracked tags = null;
@tracked user = null;
constructor() {
super(...arguments);
this.host = this.args.host;
const categoryId =
this.host.category_id || this.site.uncategorized_category_id;
const category = Category.findById(categoryId);
this.category = category;
this.tags = (this.host.tags || []).join(", ");
this.user = this.host.user;
}
@action
delete() {
return this.dialog.confirm({
message: i18n("admin.embedding.confirm_delete"),
didConfirm: () => {
return this.host.destroyRecord().then(() => {
this.args.deleteHost(this.host);
});
},
});
}
<template>
<tr class="d-admin-row__content">
<td class="d-admin-row__detail">
{{this.host.host}}
</td>
<td class="d-admin-row__detail">
{{this.host.allowed_paths}}
</td>
<td class="d-admin-row__detail">
{{categoryBadge this.category allowUncategorized=true}}
</td>
<td class="d-admin-row__detail">
{{this.tags}}
</td>
<td class="d-admin-row__detail">
{{this.user}}
</td>
<td class="d-admin-row__controls">
<div class="d-admin-row__controls-options">
<DButton
class="btn-small admin-embeddable-host-item__edit"
@route="adminEmbedding.edit"
@routeModels={{this.host}}
@label="admin.embedding.edit"
/>
<DButton
@action={{this.delete}}
@label="admin.embedding.delete"
class="btn-default btn-small admin-embeddable-host-item__delete"
/>
</div>
</td>
</tr>
</template>
}

View File

@ -1,87 +0,0 @@
{{#if this.editing}}
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.host"}}</div>
<Input
@value={{this.buffered.host}}
placeholder="example.com"
@enter={{this.save}}
class="host-name"
autofocus={{true}}
/>
</td>
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.allowed_paths"}}</div>
<Input
@value={{this.buffered.allowed_paths}}
placeholder="/blog/.*"
@enter={{this.save}}
class="path-allowlist"
/>
</td>
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.category"}}</div>
<CategoryChooser
@value={{this.category.id}}
@onChangeCategory={{fn (mut this.category)}}
class="small"
/>
</td>
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.tags"}}</div>
<TagChooser
@tags={{this.tags}}
@everyTag={{true}}
@excludeSynonyms={{true}}
@unlimitedTagCount={{true}}
@onChange={{fn (mut this.tags)}}
@options={{hash filterPlaceholder="category.tags_placeholder"}}
/>
</td>
<td class="editing-input">
<div class="label">{{i18n "admin.embedding.user"}}</div>
<UserChooser
@value={{this.user}}
@onChange={{action "onUserChange"}}
@options={{hash maximum=1 excludeCurrentUser=false}}
/>
</td>
<td class="editing-controls">
<DButton
@icon="check"
@action={{this.save}}
@disabled={{this.cantSave}}
class="btn-primary"
/>
<DButton
@icon="xmark"
@action={{this.cancel}}
@disabled={{this.host.isSaving}}
class="btn-danger"
/>
</td>
{{else}}
<td>
<div class="label">{{i18n "admin.embedding.host"}}</div>
{{this.host.host}}
</td>
<td>
<div class="label">
{{i18n "admin.embedding.allowed_paths"}}
</div>
{{this.host.allowed_paths}}
</td>
<td>
<div class="label">{{i18n "admin.embedding.category"}}</div>
{{category-badge this.category allowUncategorized=true}}
</td>
<td>
{{this.tags}}
</td>
<td>
{{this.user}}
</td>
<td class="controls">
<DButton @icon="pencil" @action={{this.edit}} />
<DButton @icon="trash-can" @action={{this.delete}} class="btn-danger" />
</td>
{{/if}}

View File

@ -1,101 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { or } from "@ember/object/computed";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { tagName } from "@ember-decorators/component";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { bufferedProperty } from "discourse/mixins/buffered-content";
import Category from "discourse/models/category";
import discourseComputed from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
@tagName("tr")
export default class EmbeddableHost extends Component.extend(
bufferedProperty("host")
) {
@service dialog;
editToggled = false;
categoryId = null;
category = null;
tags = null;
user = null;
@or("host.isNew", "editToggled") editing;
init() {
super.init(...arguments);
const host = this.host;
const categoryId = host.category_id || this.site.uncategorized_category_id;
const category = Category.findById(categoryId);
this.set("category", category);
this.set("tags", host.tags || []);
this.set("user", host.user);
}
@discourseComputed("buffered.host", "host.isSaving")
cantSave(host, isSaving) {
return isSaving || isEmpty(host);
}
@action
edit() {
this.set("editToggled", true);
}
@action
onUserChange(user) {
this.set("user", user);
}
@action
save() {
if (this.cantSave) {
return;
}
const props = this.buffered.getProperties(
"host",
"allowed_paths",
"class_name"
);
props.category_id = this.category.id;
props.tags = this.tags;
props.user =
Array.isArray(this.user) && this.user.length > 0 ? this.user[0] : null;
const host = this.host;
host
.save(props)
.then(() => {
this.set("editToggled", false);
})
.catch(popupAjaxError);
}
@action
delete() {
return this.dialog.confirm({
message: i18n("admin.embedding.confirm_delete"),
didConfirm: () => {
return this.host.destroyRecord().then(() => {
this.deleteHost(this.host);
});
},
});
}
@action
cancel() {
const host = this.host;
if (host.get("isNew")) {
this.deleteHost(host);
} else {
this.rollbackBuffer();
this.set("editToggled", false);
}
}
}

View File

@ -1,15 +0,0 @@
{{#if this.isCheckbox}}
<label for={{this.inputId}}>
<Input @checked={{this.checked}} id={{this.inputId}} @type="checkbox" />
{{i18n this.translationKey}}
</label>
{{else}}
<label for={{this.inputId}}>{{i18n this.translationKey}}</label>
<Input
@value={{this.value}}
id={{this.inputId}}
placeholder={{this.placeholder}}
/>
{{/if}}
<div class="clearfix"></div>

View File

@ -1,32 +0,0 @@
import Component from "@ember/component";
import { computed } from "@ember/object";
import { dasherize } from "@ember/string";
import { classNames } from "@ember-decorators/component";
import discourseComputed from "discourse-common/utils/decorators";
@classNames("embed-setting")
export default class EmbeddingSetting extends Component {
@discourseComputed("field")
inputId(field) {
return dasherize(field);
}
@discourseComputed("field")
translationKey(field) {
return `admin.embedding.${field}`;
}
@discourseComputed("type")
isCheckbox(type) {
return type === "checkbox";
}
@computed("value")
get checked() {
return !!this.value;
}
set checked(value) {
this.set("value", value);
}
}

View File

@ -0,0 +1,38 @@
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
export default class AdminEmbeddingCrawlersController extends Controller {
@service toasts;
@controller adminEmbedding;
get formData() {
const embedding = this.adminEmbedding.embedding;
return {
allowed_embed_selectors: embedding.allowed_embed_selectors,
blocked_embed_selectors: embedding.blocked_embed_selectors,
allowed_embed_classnames: embedding.allowed_embed_classnames,
};
}
@action
async save(data) {
const embedding = this.adminEmbedding.embedding;
try {
await embedding.update({
type: "crawlers",
...data,
});
this.toasts.success({
duration: 1500,
data: { message: i18n("admin.embedding.crawler_settings_saved") },
});
} catch (error) {
popupAjaxError(error);
}
}
}

View File

@ -0,0 +1,43 @@
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { alias } from "@ember/object/computed";
import { service } from "@ember/service";
import discourseComputed from "discourse-common/utils/decorators";
export default class AdminEmbeddingIndexController extends Controller {
@service router;
@service site;
@controller adminEmbedding;
@alias("adminEmbedding.embedding") embedding;
get showEmbeddingCode() {
return !this.site.isMobileDevice;
}
@discourseComputed("embedding.base_url")
embeddingCode(baseUrl) {
const html = `<div id='discourse-comments'></div>
<meta name='discourse-username' content='DISCOURSE_USERNAME'>
<script type="text/javascript">
DiscourseEmbed = {
discourseUrl: '${baseUrl}/',
discourseEmbedUrl: 'EMBED_URL',
// className: 'CLASS_NAME',
};
(function() {
var d = document.createElement('script'); d.type = 'text/javascript'; d.async = true;
d.src = DiscourseEmbed.discourseUrl + 'javascripts/embed.js';
(document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(d);
})();
</script>`;
return html;
}
@action
deleteHost(host) {
this.get("embedding.embeddable_hosts").removeObject(host);
}
}

View File

@ -0,0 +1,46 @@
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { isEmpty } from "@ember/utils";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
export default class AdminEmbeddingPostsAndTopicsController extends Controller {
@service toasts;
@controller adminEmbedding;
get formData() {
const embedding = this.adminEmbedding.embedding;
return {
embed_by_username: isEmpty(embedding.embed_by_username)
? null
: [embedding.embed_by_username],
embed_post_limit: embedding.embed_post_limit,
embed_title_scrubber: embedding.embed_title_scrubber,
embed_truncate: embedding.embed_truncate,
embed_unlisted: embedding.embed_unlisted,
};
}
@action
async save(data) {
const embedding = this.adminEmbedding.embedding;
try {
await embedding.update({
type: "posts_and_topics",
...data,
embed_by_username: data.embed_by_username[0],
});
this.toasts.success({
duration: 1500,
data: {
message: i18n("admin.embedding.posts_and_topics_settings_saved"),
},
});
} catch (error) {
popupAjaxError(error);
}
}
}

View File

@ -0,0 +1,3 @@
import AdminAreaSettingsBaseController from "admin/controllers/admin-area-settings-base";
export default class AdminEmbeddingSettingsController extends AdminAreaSettingsBaseController {}

View File

@ -1,61 +1,3 @@
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { action } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseComputed from "discourse-common/utils/decorators";
export default class AdminEmbeddingController extends Controller { export default class AdminEmbeddingController extends Controller {}
saved = false;
embedding = null;
// show settings if we have at least one created host
@discourseComputed("embedding.embeddable_hosts.@each.isCreated")
showSecondary() {
const hosts = this.get("embedding.embeddable_hosts");
return hosts.length && hosts.findBy("isCreated");
}
@discourseComputed("embedding.base_url")
embeddingCode(baseUrl) {
const html = `<div id='discourse-comments'></div>
<meta name='discourse-username' content='DISCOURSE_USERNAME'>
<script type="text/javascript">
DiscourseEmbed = {
discourseUrl: '${baseUrl}/',
discourseEmbedUrl: 'EMBED_URL',
// className: 'CLASS_NAME',
};
(function() {
var d = document.createElement('script'); d.type = 'text/javascript'; d.async = true;
d.src = DiscourseEmbed.discourseUrl + 'javascripts/embed.js';
(document.getElementsByTagName('head')[0] || document.getElementsByTagName('body')[0]).appendChild(d);
})();
</script>`;
return html;
}
@action
saveChanges() {
const embedding = this.embedding;
const updates = embedding.getProperties(embedding.get("fields"));
this.set("saved", false);
this.embedding
.update(updates)
.then(() => this.set("saved", true))
.catch(popupAjaxError);
}
@action
addHost() {
const host = this.store.createRecord("embeddable-host");
this.get("embedding.embeddable_hosts").pushObject(host);
}
@action
deleteHost(host) {
this.get("embedding.embeddable_hosts").removeObject(host);
}
}

View File

@ -0,0 +1,15 @@
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminEmbeddingEditRoute extends DiscourseRoute {
async model(params) {
const embedding = await this.store.find("embedding");
return embedding.embeddable_hosts.find(
(host) => host.id === parseInt(params.id, 10)
);
}
titleToken() {
return i18n("admin.embedding.host_form.edit_header");
}
}

View File

@ -0,0 +1,8 @@
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
export default class AdminEmbeddingNewRoute extends DiscourseRoute {
titleToken() {
return i18n("admin.embedding.host_form.add_header");
}
}

View File

@ -65,10 +65,21 @@ export default function () {
this.route("edit", { path: "/:id" }); this.route("edit", { path: "/:id" });
} }
); );
this.route("adminEmbedding", { this.route(
path: "/embedding", "adminEmbedding",
resetNamespace: true, {
}); path: "/embedding",
resetNamespace: true,
},
function () {
this.route("index", { path: "/" });
this.route("settings");
this.route("postsAndTopics", { path: "/posts_and_topics" });
this.route("crawlers");
this.route("new");
this.route("edit", { path: "/:id" });
}
);
this.route( this.route(
"adminCustomizeEmailTemplates", "adminCustomizeEmailTemplates",
{ path: "/email_templates", resetNamespace: true }, { path: "/email_templates", resetNamespace: true },

View File

@ -0,0 +1,32 @@
<DPageSubheader
@titleLabel={{i18n "admin.embedding.crawlers"}}
@descriptionLabel={{i18n "admin.embedding.crawlers_description"}}
/>
<Form @onSubmit={{this.save}} @data={{this.formData}} as |form|>
<form.Field
@name="allowed_embed_selectors"
@title={{i18n "admin.embedding.allowed_embed_selectors"}}
@format="large"
as |field|
>
<field.Input placeholder="article, #story, .post" />
</form.Field>
<form.Field
@name="blocked_embed_selectors"
@title={{i18n "admin.embedding.blocked_embed_selectors"}}
@format="large"
as |field|
>
<field.Input placeholder=".ad-unit, header" />
</form.Field>
<form.Field
@name="allowed_embed_classnames"
@title={{i18n "admin.embedding.allowed_embed_classnames"}}
@format="large"
as |field|
>
<field.Input placeholder="emoji, classname" />
</form.Field>
<form.Submit @label="admin.embedding.save" />
</Form>

View File

@ -0,0 +1 @@
<AdminEmbeddingHostForm @host={{this.model}} />

View File

@ -0,0 +1,49 @@
{{#if this.embedding.embeddable_hosts}}
{{#if this.showEmbeddingCode}}
<AdminConfigAreaCard
@heading="admin.embedding.configuration_snippet"
@collapsable={{true}}
@collapsed={{true}}
class="admin-embedding-index__code"
>
<:content>
{{html-safe (i18n "admin.embedding.sample")}}
<HighlightedCode @code={{this.embeddingCode}} @lang="html" />
</:content>
</AdminConfigAreaCard>
{{/if}}
<table class="d-admin-table">
<thead>
<th>{{i18n "admin.embedding.host"}}</th>
<th>{{i18n "admin.embedding.allowed_paths"}}</th>
<th>{{i18n "admin.embedding.category"}}</th>
<th>{{i18n "admin.embedding.tags"}}</th>
{{#if this.embedding.embed_by_username}}
<th>{{i18n
"admin.embedding.post_author_with_default"
author=this.embedding.embed_by_username
}}</th>
{{else}}
<th>{{i18n "admin.embedding.post_author"}}</th>
{{/if}}
</thead>
<tbody>
{{#each this.embedding.embeddable_hosts as |host|}}
<EmbeddableHost @host={{host}} @deleteHost={{action "deleteHost"}} />
{{/each}}
</tbody>
</table>
{{else}}
<AdminConfigAreaEmptyList
@ctaLabel="admin.embedding.add_host"
@ctaRoute="adminEmbedding.new"
@ctaClass="admin-embedding__add-host"
@emptyLabel="admin.embedding.get_started"
/>
{{/if}}
<PluginOutlet
@name="after-embeddable-hosts-table"
@outletArgs={{hash embedding=this.embedding}}
/>

View File

@ -0,0 +1 @@
<AdminEmbeddingHostForm />

View File

@ -0,0 +1,53 @@
<DPageSubheader @titleLabel={{i18n "admin.embedding.posts_and_topics"}} />
<Form @onSubmit={{this.save}} @data={{this.formData}} as |form|>
<form.Field
@name="embed_by_username"
@title={{i18n "admin.embedding.embed_by_username"}}
@validation="required"
as |field|
>
<field.Custom>
<UserChooser
@value={{field.value}}
@onChange={{field.set}}
@options={{hash maximum=1 excludeCurrentUser=false}}
class="admin-embedding-posts-and-topics-form__embed_by_username"
/>
</field.Custom>
</form.Field>
<form.Field
@name="embed_post_limit"
@title={{i18n "admin.embedding.embed_post_limit"}}
@format="large"
as |field|
>
<field.Input />
</form.Field>
<form.Field
@name="embed_title_scrubber"
@title={{i18n "admin.embedding.embed_title_scrubber"}}
@format="large"
as |field|
>
<field.Input placeholder="- site.com$" />
</form.Field>
<form.CheckboxGroup as |checkboxGroup|>
<checkboxGroup.Field
@name="embed_truncate"
@title={{i18n "admin.embedding.embed_truncate"}}
as |field|
>
<field.Checkbox />
</checkboxGroup.Field>
<checkboxGroup.Field
@name="embed_unlisted"
@title={{i18n "admin.embedding.embed_unlisted"}}
as |field|
>
<field.Checkbox />
</checkboxGroup.Field>
</form.CheckboxGroup>
<form.Submit @label="admin.embedding.save" />
</Form>

View File

@ -0,0 +1,6 @@
<AdminAreaSettings
@area="embedding"
@path="/admin/customize/embedding/settings"
@filter={{this.filter}}
@adminSettingsFilterChangedCallback={{this.adminSettingsFilterChangedCallback}}
/>

View File

@ -1,111 +1,49 @@
<div class="embeddable-hosts"> <div class="admin-embedding admin-config-page">
{{#if this.embedding.embeddable_hosts}} <DPageHeader
<table class="embedding grid"> @titleLabel={{i18n "admin.embedding.title"}}
<thead> @descriptionLabel={{i18n "admin.embedding.description"}}
<th style="width: 18%">{{i18n "admin.embedding.host"}}</th> @learnMoreUrl="https://meta.discourse.org/t/embed-discourse-comments-on-another-website-via-javascript/31963"
<th style="width: 18%">{{i18n "admin.embedding.allowed_paths"}}</th> >
<th style="width: 18%">{{i18n "admin.embedding.category"}}</th> <:breadcrumbs>
<th style="width: 18%">{{i18n "admin.embedding.tags"}}</th> <DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
{{#if this.embedding.embed_by_username}} <DBreadcrumbsItem
<th style="width: 18%">{{i18n @path="/admin/customize/embedding"
"admin.embedding.post_author" @label={{i18n "admin.embedding.title"}}
author=this.embedding.embed_by_username />
}}</th> </:breadcrumbs>
{{else}} <:actions as |actions|>
<th style="width: 18%">{{i18n "admin.embedding.post_author"}}</th> <actions.Primary
{{/if}} @route="adminEmbedding.new"
<th style="width: 10%">&nbsp;</th> @title="admin.embedding.add_host"
</thead> @label="admin.embedding.add_host"
<tbody> class="admin-embedding__header-add-host"
{{#each this.embedding.embeddable_hosts as |host|}} />
<EmbeddableHost @host={{host}} @deleteHost={{action "deleteHost"}} /> </:actions>
{{/each}} <:tabs>
</tbody> <NavItem
</table> @route="adminEmbedding.settings"
{{else}} @label="admin.embedding.nav.settings"
<p>{{i18n "admin.embedding.get_started"}}</p> class="admin-embedding-tabs__settings"
{{/if}} />
<NavItem
@route="adminEmbedding.index"
@label="admin.embedding.nav.hosts"
class="admin-embedding-tabs__hosts"
/>
<NavItem
@route="adminEmbedding.postsAndTopics"
@label="admin.embedding.nav.posts_and_topics"
class="admin-embedding-tabs__posts-and-topics"
/>
<NavItem
@route="adminEmbedding.crawlers"
@label="admin.embedding.nav.crawlers"
class="admin-embedding-tabs__crawlers"
/>
</:tabs>
</DPageHeader>
<DButton <div class="admin-container admin-config-page__main-area">
@label="admin.embedding.add_host" {{outlet}}
@action={{this.addHost}}
@icon="plus"
class="btn-primary add-host"
/>
<PluginOutlet
@name="after-embeddable-hosts-table"
@outletArgs={{hash embedding=this.embedding}}
/>
</div>
{{#if this.showSecondary}}
<div class="embedding-secondary">
{{html-safe (i18n "admin.embedding.sample")}}
<HighlightedCode @code={{this.embeddingCode}} @lang="html" />
</div> </div>
</div>
<hr />
<div class="embedding-secondary">
<h3>{{i18n "admin.embedding.settings"}}</h3>
<EmbeddingSetting
@field="embed_by_username"
@value={{this.embedding.embed_by_username}}
/>
<EmbeddingSetting
@field="embed_post_limit"
@value={{this.embedding.embed_post_limit}}
/>
<EmbeddingSetting
@field="embed_title_scrubber"
@value={{this.embedding.embed_title_scrubber}}
@placeholder="- site.com$"
/>
<EmbeddingSetting
@field="embed_truncate"
@value={{this.embedding.embed_truncate}}
@type="checkbox"
/>
<EmbeddingSetting
@field="embed_unlisted"
@value={{this.embedding.embed_unlisted}}
@type="checkbox"
/>
</div>
<div class="embedding-secondary">
<h3>{{i18n "admin.embedding.crawling_settings"}}</h3>
<p class="description">{{i18n "admin.embedding.crawling_description"}}</p>
<EmbeddingSetting
@field="allowed_embed_selectors"
@value={{this.embedding.allowed_embed_selectors}}
@placeholder="article, #story, .post"
/>
<EmbeddingSetting
@field="blocked_embed_selectors"
@value={{this.embedding.blocked_embed_selectors}}
@placeholder=".ad-unit, header"
/>
<EmbeddingSetting
@field="allowed_embed_classnames"
@value={{this.embedding.allowed_embed_classnames}}
@placeholder="emoji, classname"
/>
</div>
<div class="embedding-secondary">
<DButton
@label="admin.embedding.save"
@action={{this.saveChanges}}
@disabled={{this.embedding.isSaving}}
class="btn-primary embed-save"
/>
{{#if this.saved}}{{i18n "saved"}}{{/if}}
</div>
{{/if}}

View File

@ -68,7 +68,7 @@
class="btn-small admin-permalink-item__edit" class="btn-small admin-permalink-item__edit"
@route="adminPermalinks.edit" @route="adminPermalinks.edit"
@routeModels={{pl}} @routeModels={{pl}}
@label="admin.config_areas.flags.edit" @label="admin.config_areas.permalinks.edit"
/> />
<DMenu <DMenu
@ -84,7 +84,7 @@
@action={{fn this.destroyRecord pl}} @action={{fn this.destroyRecord pl}}
@icon="trash-can" @icon="trash-can"
class="btn-transparent admin-permalink-item__delete" class="btn-transparent admin-permalink-item__delete"
@label="admin.config_areas.flags.delete" @label="admin.config_areas.permalinks.delete"
/> />
</dropdown.item> </dropdown.item>
</DropdownMenu> </DropdownMenu>

View File

@ -16,7 +16,6 @@
@route="adminPermalinks.new" @route="adminPermalinks.new"
@title="admin.permalink.add" @title="admin.permalink.add"
@label="admin.permalink.add" @label="admin.permalink.add"
@icon="plus"
class="admin-permalinks__header-add-permalink" class="admin-permalinks__header-add-permalink"
/> />
</:actions> </:actions>

View File

@ -36,6 +36,20 @@ module("Integration | Component | AdminConfigAreaCard", function (hooks) {
assert.dom(".admin-config-area-card__content").exists(); assert.dom(".admin-config-area-card__content").exists();
}); });
test("renders admin config area card with toggle button and collapsed by default", async function (assert) {
await render(<template>
<AdminConfigAreaCard
@translatedHeading="test heading"
@collapsable={{true}}
@collapsed={{true}}
><:content>test</:content></AdminConfigAreaCard>
</template>);
assert.dom(".admin-config-area-card__title").exists();
assert.dom(".admin-config-area-card__toggle-button").exists();
assert.dom(".admin-config-area-card__content").doesNotExist();
});
test("renders admin config area card with header action", async function (assert) { test("renders admin config area card with header action", async function (assert) {
await render(<template> await render(<template>
<AdminConfigAreaCard <AdminConfigAreaCard

View File

@ -897,26 +897,17 @@
} }
} }
.embedding-secondary { .admin-embedding {
h3 { .admin-embeddable-host-item__delete {
margin: 1em 0; &:hover {
} svg.d-icon {
margin-bottom: 2em; color: var(--primary-medium);
.embed-setting { }
input[type="text"] { }
width: 50%; svg.d-icon {
color: var(--primary-low-mid);
} }
margin: 0.75em 0;
} }
p.description {
color: var(--primary-medium);
margin-bottom: 1em;
max-width: 700px;
}
}
.embedding td input {
margin-bottom: 0;
} }
.user-fields { .user-fields {

View File

@ -26,7 +26,7 @@ class Admin::EmbeddableHostsController < Admin::AdminController
host.host = params[:embeddable_host][:host] host.host = params[:embeddable_host][:host]
host.allowed_paths = params[:embeddable_host][:allowed_paths] host.allowed_paths = params[:embeddable_host][:allowed_paths]
host.category_id = params[:embeddable_host][:category_id] host.category_id = params[:embeddable_host][:category_id]
host.category_id = SiteSetting.uncategorized_category_id if host.category_id.blank? host.category_id = SiteSetting.uncategorized_category_id if host.category.blank?
username = params[:embeddable_host][:user] username = params[:embeddable_host][:user]

View File

@ -8,11 +8,11 @@ class Admin::EmbeddingController < Admin::AdminController
end end
def update def update
if params[:embedding][:embed_by_username].blank? raise InvalidAccess if !(%w[posts_and_topics crawlers].include?(params[:embedding][:type]))
return render_json_error(I18n.t("site_settings.embed_username_required"))
end
Embedding.settings.each { |s| @embedding.public_send("#{s}=", params[:embedding][s]) } Embedding
.send("#{params[:embedding][:type]}_settings")
.each { |s| @embedding.public_send("#{s}=", params[:embedding][s]) }
if @embedding.save if @embedding.save
fetch_embedding fetch_embedding
@ -22,6 +22,12 @@ class Admin::EmbeddingController < Admin::AdminController
end end
end end
def new
end
def edit
end
protected protected
def fetch_embedding def fetch_embedding

View File

@ -6,16 +6,15 @@ class Embedding < OpenStruct
include HasErrors include HasErrors
def self.settings def self.settings
%i[ posts_and_topics_settings | crawlers_settings
embed_by_username end
embed_post_limit
embed_title_scrubber def self.posts_and_topics_settings
embed_truncate %i[embed_by_username embed_post_limit embed_title_scrubber embed_truncate embed_unlisted]
embed_unlisted end
allowed_embed_selectors
blocked_embed_selectors def self.crawlers_settings
allowed_embed_classnames %i[allowed_embed_selectors blocked_embed_selectors allowed_embed_classnames]
]
end end
def base_url def base_url

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SiteSetting < ActiveRecord::Base class SiteSetting < ActiveRecord::Base
VALID_AREAS = %w[flags about emojis permalinks notifications] VALID_AREAS = %w[about embedding emojis flags notifications permalinks]
extend GlobalPath extend GlobalPath
extend SiteSettingExtension extend SiteSettingExtension

View File

@ -5750,6 +5750,12 @@ en:
title: "More options" title: "More options"
move_up: "Move up" move_up: "Move up"
move_down: "Move down" move_down: "Move down"
permalinks:
edit: "Edit"
delete: "Delete"
embeddable_host:
edit: "Edit"
delete: "Delete"
look_and_feel: look_and_feel:
title: "Look and feel" title: "Look and feel"
description: "Customize and brand your Discourse site, giving it a distinctive style." description: "Customize and brand your Discourse site, giving it a distinctive style."
@ -7310,6 +7316,7 @@ en:
embedding: embedding:
get_started: "If you'd like to embed Discourse on another website, begin by adding its host." get_started: "If you'd like to embed Discourse on another website, begin by adding its host."
delete: "Delete"
confirm_delete: "Are you sure you want to delete that host?" confirm_delete: "Are you sure you want to delete that host?"
sample: | sample: |
<p>Paste the following HTML code into your site to create and embed Discourse topics. Replace <b>EMBED_URL</b> with the canonical URL of the page you are embedding it on.</p> <p>Paste the following HTML code into your site to create and embed Discourse topics. Replace <b>EMBED_URL</b> with the canonical URL of the page you are embedding it on.</p>
@ -7318,16 +7325,18 @@ en:
<p>Replace <b>DISCOURSE_USERNAME</b> with the Discourse username of the author that should create the topic. Discourse will automatically lookup the user by the <code>content</code> attribute of the <code>&lt;meta&gt;</code> tags with <code>name</code> attribute set to <code>discourse-username</code> or <code>author</code>. The <code>discourseUserName</code> parameter has been deprecated and will be removed in Discourse 3.2.</p> <p>Replace <b>DISCOURSE_USERNAME</b> with the Discourse username of the author that should create the topic. Discourse will automatically lookup the user by the <code>content</code> attribute of the <code>&lt;meta&gt;</code> tags with <code>name</code> attribute set to <code>discourse-username</code> or <code>author</code>. The <code>discourseUserName</code> parameter has been deprecated and will be removed in Discourse 3.2.</p>
title: "Embedding" title: "Embedding"
host: "Allowed Hosts" description: "Discourse has the ability to embed the comments from a topic in a remote site using a Javascript API that creates an IFRAME"
allowed_paths: "Path Allowlist" host: "Allowed hosts"
edit: "edit" allowed_paths: "Path allowlist"
category: "Post to Category" edit: "Edit"
tags: "Topic Tags" category: "Post to category"
post_author: "Post Author - Defaults to %{author}" tags: "Topic tags"
add_host: "Add Host" post_author: "Post author"
settings: "Embedding Settings" post_author_with_default: "Post author (defaults to %{author})"
crawling_settings: "Crawler Settings" add_host: "Add host"
crawling_description: "When Discourse creates topics for your posts, if no RSS/ATOM feed is present it will attempt to parse your content out of your HTML. Sometimes it can be challenging to extract your content, so we provide the ability to specify CSS rules to make extraction easier." posts_and_topics: "Posts and Topics configuration"
crawlers: "Crawlers configuration"
crawlers_description: "When Discourse creates topics for your posts, if no RSS/ATOM feed is present it will attempt to parse your content out of your HTML. Sometimes it can be challenging to extract your content, so we provide the ability to specify CSS rules to make extraction easier."
embed_by_username: "Username for topic creation" embed_by_username: "Username for topic creation"
embed_post_limit: "Maximum number of posts to embed" embed_post_limit: "Maximum number of posts to embed"
@ -7337,7 +7346,20 @@ en:
allowed_embed_selectors: "CSS selector for elements that are allowed in embeds" allowed_embed_selectors: "CSS selector for elements that are allowed in embeds"
blocked_embed_selectors: "CSS selector for elements that are removed from embeds" blocked_embed_selectors: "CSS selector for elements that are removed from embeds"
allowed_embed_classnames: "Allowed CSS class names" allowed_embed_classnames: "Allowed CSS class names"
save: "Save Embedding Settings" save: "Save"
posts_and_topics_settings_saved: "Posts and Topics settings saved"
crawler_settings_saved: "Crawler settings saved"
back: "Back to Embedding"
configuration_snippet: "Configuration snippet"
host_form:
add_header: "Add host"
edit_header: "Edit host"
save: "Save"
nav:
hosts: "Hosts"
settings: "Settings"
posts_and_topics: "Posts and Topics"
crawlers: "Crawlers"
permalink: permalink:
title: "Permalinks" title: "Permalinks"

View File

@ -223,6 +223,7 @@ Discourse::Application.routes.draw do
get "config/permalinks" => "permalinks#index", :constraints => AdminConstraint.new get "config/permalinks" => "permalinks#index", :constraints => AdminConstraint.new
get "customize/embedding" => "embedding#show", :constraints => AdminConstraint.new get "customize/embedding" => "embedding#show", :constraints => AdminConstraint.new
put "customize/embedding" => "embedding#update", :constraints => AdminConstraint.new put "customize/embedding" => "embedding#update", :constraints => AdminConstraint.new
get "customize/embedding/:id" => "embedding#edit", :constraints => AdminConstraint.new
resources :themes, resources :themes,
only: %i[index create show update destroy], only: %i[index create show update destroy],

View File

@ -1212,14 +1212,30 @@ posting:
choices: choices:
- code-fences - code-fences
- 4-spaces-indent - 4-spaces-indent
embed_any_origin: false embed_any_origin:
embed_topics_list: false default: false
embed_set_canonical_url: false area: "embedding"
embed_unlisted: false embed_topics_list:
import_embed_unlisted: true default: false
embed_truncate: true area: "embedding"
embed_support_markdown: false embed_set_canonical_url:
allowed_embed_selectors: "" default: false
area: "embedding"
embed_unlisted:
default: false
area: "embedding"
import_embed_unlisted:
default: true
area: "embedding"
embed_truncate:
default: true
area: "embedding"
embed_support_markdown:
default: false
area: "embedding"
allowed_embed_selectors:
default: ""
area: "embedding"
allowed_href_schemes: allowed_href_schemes:
client: true client: true
default: "" default: ""

View File

@ -43,10 +43,11 @@ RSpec.describe Admin::EmbeddingController do
context "when logged in as an admin" do context "when logged in as an admin" do
before { sign_in(admin) } before { sign_in(admin) }
it "updates embedding" do it "updates posts and topics settings" do
put "/admin/customize/embedding.json", put "/admin/customize/embedding.json",
params: { params: {
embedding: { embedding: {
type: "posts_and_topics",
embed_by_username: "system", embed_by_username: "system",
embed_post_limit: 200, embed_post_limit: 200,
}, },
@ -56,6 +57,21 @@ RSpec.describe Admin::EmbeddingController do
expect(response.parsed_body["embedding"]["embed_by_username"]).to eq("system") expect(response.parsed_body["embedding"]["embed_by_username"]).to eq("system")
expect(response.parsed_body["embedding"]["embed_post_limit"]).to eq(200) expect(response.parsed_body["embedding"]["embed_post_limit"]).to eq(200)
end end
it "updates crawlers settings" do
put "/admin/customize/embedding.json",
params: {
embedding: {
type: "crawlers",
allowed_embed_selectors: "article",
blocked_embed_selectors: "p",
},
}
expect(response.status).to eq(200)
expect(response.parsed_body["embedding"]["allowed_embed_selectors"]).to eq("article")
expect(response.parsed_body["embedding"]["blocked_embed_selectors"]).to eq("p")
end
end end
shared_examples "embedding updates not allowed" do shared_examples "embedding updates not allowed" do
@ -63,6 +79,7 @@ RSpec.describe Admin::EmbeddingController do
put "/admin/customize/embedding.json", put "/admin/customize/embedding.json",
params: { params: {
embedding: { embedding: {
type: "posts_and_topics",
embed_by_username: "system", embed_by_username: "system",
embed_post_limit: 200, embed_post_limit: 200,
}, },

View File

@ -3,59 +3,75 @@
RSpec.describe "Admin EmbeddableHost Management", type: :system do RSpec.describe "Admin EmbeddableHost Management", type: :system do
fab!(:admin) fab!(:admin)
fab!(:author) { Fabricate(:admin) } fab!(:author) { Fabricate(:admin) }
fab!(:author_2) { Fabricate(:admin) }
fab!(:category) fab!(:category)
fab!(:category2) { Fabricate(:category) } fab!(:category_2) { Fabricate(:category) }
fab!(:tag) fab!(:tag)
fab!(:tag2) { Fabricate(:tag) } fab!(:tag_2) { Fabricate(:tag) }
before { sign_in(admin) } before { sign_in(admin) }
it "allows admin to add and edit embeddable hosts" do let(:admin_embedding_page) { PageObjects::Pages::AdminEmbedding.new }
visit "/admin/customize/embedding" let(:admin_embedding_host_form_page) { PageObjects::Pages::AdminEmbeddingHostForm.new }
let(:admin_embedding_posts_and_topics_page) do
PageObjects::Pages::AdminEmbeddingPostsAndTopics.new
end
find("button.btn-icon-text", text: "Add Host").click it "allows admin to add, edit and delete embeddable hosts" do
within find("tr.ember-view") do admin_embedding_page.visit
find('input[placeholder="example.com"]').set("awesome-discourse-site.local")
find('input[placeholder="/blog/.*"]').set("/blog/.*")
category_chooser = PageObjects::Components::SelectKit.new(".category-chooser") expect(page).not_to have_css(".admin-embedding-index__code")
category_chooser.expand
category_chooser.select_row_by_name(category.name)
tag_chooser = PageObjects::Components::SelectKit.new(".tag-chooser") admin_embedding_page.click_add_host
tag_chooser.expand
tag_chooser.select_row_by_name(tag.name) admin_embedding_host_form_page.fill_in_allowed_hosts("awesome-discourse-site.local")
admin_embedding_host_form_page.fill_in_path_allow_list("/blog/.*")
admin_embedding_host_form_page.fill_in_category(category)
admin_embedding_host_form_page.fill_in_tags(tag)
admin_embedding_host_form_page.fill_in_post_author(author)
admin_embedding_host_form_page.click_save
find(".user-chooser").click
find(".select-kit-body .select-kit-filter input").fill_in with: author.username
find(".select-kit-body", text: author.username).click
end
find("td.editing-controls .btn.btn-primary").click
expect(page).to have_content("awesome-discourse-site.local") expect(page).to have_content("awesome-discourse-site.local")
expect(page).to have_content("/blog/.*") expect(page).to have_content("/blog/.*")
expect(page).not_to have_content("#{tag.name},#{tag2.name}")
expect(page).to have_content("#{tag.name}") expect(page).to have_content("#{tag.name}")
expect(page).to have_content("#{category.name}")
expect(page).to have_content("#{author.username}")
# Editing expect(page).to have_css(".admin-embedding-index__code")
find(".embeddable-hosts tr:first-child .controls svg.d-icon-pencil").find(:xpath, "..").click admin_embedding_page.click_edit_host
within find(".embeddable-hosts tr:first-child.ember-view") do admin_embedding_host_form_page.fill_in_allowed_hosts("updated-example.com")
find('input[placeholder="example.com"]').set("updated-example.com") admin_embedding_host_form_page.fill_in_path_allow_list("/updated-blog/.*")
find('input[placeholder="/blog/.*"]').set("/updated-blog/.*") admin_embedding_host_form_page.fill_in_category(category_2)
admin_embedding_host_form_page.fill_in_tags(tag_2)
admin_embedding_host_form_page.fill_in_post_author(author_2)
admin_embedding_host_form_page.click_save
category_chooser = PageObjects::Components::SelectKit.new(".category-chooser")
category_chooser.expand
category_chooser.select_row_by_name(category2.name)
tag_chooser = PageObjects::Components::SelectKit.new(".tag-chooser")
tag_chooser.expand
tag_chooser.select_row_by_name(tag2.name)
end
find("td.editing-controls .btn.btn-primary").click
expect(page).to have_content("updated-example.com") expect(page).to have_content("updated-example.com")
expect(page).to have_content("/updated-blog/.*") expect(page).to have_content("/updated-blog/.*")
expect(page).to have_content("#{tag.name},#{tag2.name}") expect(page).to have_content("#{tag.name}, #{tag_2.name}")
expect(page).to have_content("#{category_2.name}")
expect(page).to have_content("#{author_2.username}")
admin_embedding_page.click_delete
admin_embedding_page.confirm_delete
expect(page).not_to have_css(".admin-embedding-index__code")
end
it "allows admin to save posts and topics settings" do
Fabricate(:embeddable_host)
admin_embedding_page.visit
expect(page).not_to have_content("#{author.username}")
admin_embedding_page.click_posts_and_topics_tab
admin_embedding_posts_and_topics_page.fill_in_embed_by_username(author)
admin_embedding_posts_and_topics_page.click_save
admin_embedding_page.click_hosts_tab
expect(page).to have_content("#{author.username}")
end end
end end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminEmbedding < PageObjects::Pages::Base
def visit
page.visit("/admin/customize/embedding")
self
end
def click_posts_and_topics_tab
find(".admin-embedding-tabs__posts-and-topics").click
end
def click_hosts_tab
find(".admin-embedding-tabs__hosts").click
end
def click_add_host
find(".admin-embedding__header-add-host").click
self
end
def click_edit_host
find(".admin-embeddable-host-item__edit").click
self
end
def click_delete
find(".admin-embeddable-host-item__delete").click
self
end
def confirm_delete
find(".dialog-footer .btn-primary").click
expect(page).to have_no_css(".dialog-body", wait: Capybara.default_max_wait_time * 3)
self
end
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminEmbeddingHostForm < PageObjects::Pages::Base
def fill_in_allowed_hosts(url)
form.field("host").fill_in(url)
self
end
def fill_in_path_allow_list(path)
form.field("allowed_paths").fill_in(path)
self
end
def fill_in_category(category)
dropdown = PageObjects::Components::SelectKit.new(".admin-embedding-host-form__category")
dropdown.expand
dropdown.search(category.name)
dropdown.select_row_by_value(category.id)
dropdown.collapse
self
end
def fill_in_tags(tag)
dropdown = PageObjects::Components::SelectKit.new(".admin-embedding-host-form__tags")
dropdown.expand
dropdown.search(tag.name)
dropdown.select_row_by_value(tag.name)
dropdown.collapse
self
end
def fill_in_post_author(author)
dropdown = PageObjects::Components::SelectKit.new(".admin-embedding-host-form__post_author")
dropdown.expand
dropdown.search(author.username)
dropdown.select_row_by_value(author.username)
dropdown.collapse
self
end
def click_save
form.submit
expect(page).to have_css(".d-admin-table")
end
def form
@form ||= PageObjects::Components::FormKit.new(".admin-embedding-host-form .form-kit")
end
end
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module PageObjects
module Pages
class AdminEmbeddingPostsAndTopics < PageObjects::Pages::Base
def fill_in_embed_by_username(author)
dropdown =
PageObjects::Components::SelectKit.new(
".admin-embedding-posts-and-topics-form__embed_by_username",
)
dropdown.expand
dropdown.search(author.username)
dropdown.select_row_by_value(author.username)
dropdown.collapse
self
end
def click_save
form = PageObjects::Components::FormKit.new(".admin-embedding .form-kit")
form.submit
end
end
end
end