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";
export default class AdminConfigAreaCard extends Component {
@tracked collapsed = false;
@tracked collapsed = this.args.collapsed;
get computedHeading() {
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 { action } from "@ember/object";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseComputed from "discourse-common/utils/decorators";
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);
}
}
export default class AdminEmbeddingController extends Controller {}

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("adminEmbedding", {
this.route(
"adminEmbedding",
{
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(
"adminCustomizeEmailTemplates",
{ 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">
{{#if this.embedding.embeddable_hosts}}
<table class="embedding grid">
<thead>
<th style="width: 18%">{{i18n "admin.embedding.host"}}</th>
<th style="width: 18%">{{i18n "admin.embedding.allowed_paths"}}</th>
<th style="width: 18%">{{i18n "admin.embedding.category"}}</th>
<th style="width: 18%">{{i18n "admin.embedding.tags"}}</th>
{{#if this.embedding.embed_by_username}}
<th style="width: 18%">{{i18n
"admin.embedding.post_author"
author=this.embedding.embed_by_username
}}</th>
{{else}}
<th style="width: 18%">{{i18n "admin.embedding.post_author"}}</th>
{{/if}}
<th style="width: 10%">&nbsp;</th>
</thead>
<tbody>
{{#each this.embedding.embeddable_hosts as |host|}}
<EmbeddableHost @host={{host}} @deleteHost={{action "deleteHost"}} />
{{/each}}
</tbody>
</table>
{{else}}
<p>{{i18n "admin.embedding.get_started"}}</p>
{{/if}}
<DButton
<div class="admin-embedding admin-config-page">
<DPageHeader
@titleLabel={{i18n "admin.embedding.title"}}
@descriptionLabel={{i18n "admin.embedding.description"}}
@learnMoreUrl="https://meta.discourse.org/t/embed-discourse-comments-on-another-website-via-javascript/31963"
>
<:breadcrumbs>
<DBreadcrumbsItem @path="/admin" @label={{i18n "admin_title"}} />
<DBreadcrumbsItem
@path="/admin/customize/embedding"
@label={{i18n "admin.embedding.title"}}
/>
</:breadcrumbs>
<:actions as |actions|>
<actions.Primary
@route="adminEmbedding.new"
@title="admin.embedding.add_host"
@label="admin.embedding.add_host"
@action={{this.addHost}}
@icon="plus"
class="btn-primary add-host"
class="admin-embedding__header-add-host"
/>
</:actions>
<:tabs>
<NavItem
@route="adminEmbedding.settings"
@label="admin.embedding.nav.settings"
class="admin-embedding-tabs__settings"
/>
<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>
<PluginOutlet
@name="after-embeddable-hosts-table"
@outletArgs={{hash embedding=this.embedding}}
/>
<div class="admin-container admin-config-page__main-area">
{{outlet}}
</div>
</div>
{{#if this.showSecondary}}
<div class="embedding-secondary">
{{html-safe (i18n "admin.embedding.sample")}}
<HighlightedCode @code={{this.embeddingCode}} @lang="html" />
</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"
@route="adminPermalinks.edit"
@routeModels={{pl}}
@label="admin.config_areas.flags.edit"
@label="admin.config_areas.permalinks.edit"
/>
<DMenu
@ -84,7 +84,7 @@
@action={{fn this.destroyRecord pl}}
@icon="trash-can"
class="btn-transparent admin-permalink-item__delete"
@label="admin.config_areas.flags.delete"
@label="admin.config_areas.permalinks.delete"
/>
</dropdown.item>
</DropdownMenu>

View File

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

View File

@ -36,6 +36,20 @@ module("Integration | Component | AdminConfigAreaCard", function (hooks) {
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) {
await render(<template>
<AdminConfigAreaCard

View File

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

View File

@ -26,7 +26,7 @@ class Admin::EmbeddableHostsController < Admin::AdminController
host.host = params[:embeddable_host][:host]
host.allowed_paths = params[:embeddable_host][:allowed_paths]
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]

View File

@ -8,11 +8,11 @@ class Admin::EmbeddingController < Admin::AdminController
end
def update
if params[:embedding][:embed_by_username].blank?
return render_json_error(I18n.t("site_settings.embed_username_required"))
end
raise InvalidAccess if !(%w[posts_and_topics crawlers].include?(params[:embedding][:type]))
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
fetch_embedding
@ -22,6 +22,12 @@ class Admin::EmbeddingController < Admin::AdminController
end
end
def new
end
def edit
end
protected
def fetch_embedding

View File

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

View File

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

View File

@ -5750,6 +5750,12 @@ en:
title: "More options"
move_up: "Move up"
move_down: "Move down"
permalinks:
edit: "Edit"
delete: "Delete"
embeddable_host:
edit: "Edit"
delete: "Delete"
look_and_feel:
title: "Look and feel"
description: "Customize and brand your Discourse site, giving it a distinctive style."
@ -7310,6 +7316,7 @@ en:
embedding:
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?"
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>
@ -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>
title: "Embedding"
host: "Allowed Hosts"
allowed_paths: "Path Allowlist"
edit: "edit"
category: "Post to Category"
tags: "Topic Tags"
post_author: "Post Author - Defaults to %{author}"
add_host: "Add Host"
settings: "Embedding Settings"
crawling_settings: "Crawler Settings"
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."
description: "Discourse has the ability to embed the comments from a topic in a remote site using a Javascript API that creates an IFRAME"
host: "Allowed hosts"
allowed_paths: "Path allowlist"
edit: "Edit"
category: "Post to category"
tags: "Topic tags"
post_author: "Post author"
post_author_with_default: "Post author (defaults to %{author})"
add_host: "Add host"
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_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"
blocked_embed_selectors: "CSS selector for elements that are removed from embeds"
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:
title: "Permalinks"

View File

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

View File

@ -1212,14 +1212,30 @@ posting:
choices:
- code-fences
- 4-spaces-indent
embed_any_origin: false
embed_topics_list: false
embed_set_canonical_url: false
embed_unlisted: false
import_embed_unlisted: true
embed_truncate: true
embed_support_markdown: false
allowed_embed_selectors: ""
embed_any_origin:
default: false
area: "embedding"
embed_topics_list:
default: false
area: "embedding"
embed_set_canonical_url:
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:
client: true
default: ""

View File

@ -43,10 +43,11 @@ RSpec.describe Admin::EmbeddingController do
context "when logged in as an admin" do
before { sign_in(admin) }
it "updates embedding" do
it "updates posts and topics settings" do
put "/admin/customize/embedding.json",
params: {
embedding: {
type: "posts_and_topics",
embed_by_username: "system",
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_post_limit"]).to eq(200)
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
shared_examples "embedding updates not allowed" do
@ -63,6 +79,7 @@ RSpec.describe Admin::EmbeddingController do
put "/admin/customize/embedding.json",
params: {
embedding: {
type: "posts_and_topics",
embed_by_username: "system",
embed_post_limit: 200,
},

View File

@ -3,59 +3,75 @@
RSpec.describe "Admin EmbeddableHost Management", type: :system do
fab!(:admin)
fab!(:author) { Fabricate(:admin) }
fab!(:author_2) { Fabricate(:admin) }
fab!(:category)
fab!(:category2) { Fabricate(:category) }
fab!(:category_2) { Fabricate(:category) }
fab!(:tag)
fab!(:tag2) { Fabricate(:tag) }
fab!(:tag_2) { Fabricate(:tag) }
before { sign_in(admin) }
it "allows admin to add and edit embeddable hosts" do
visit "/admin/customize/embedding"
find("button.btn-icon-text", text: "Add Host").click
within find("tr.ember-view") do
find('input[placeholder="example.com"]').set("awesome-discourse-site.local")
find('input[placeholder="/blog/.*"]').set("/blog/.*")
category_chooser = PageObjects::Components::SelectKit.new(".category-chooser")
category_chooser.expand
category_chooser.select_row_by_name(category.name)
tag_chooser = PageObjects::Components::SelectKit.new(".tag-chooser")
tag_chooser.expand
tag_chooser.select_row_by_name(tag.name)
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
let(:admin_embedding_page) { PageObjects::Pages::AdminEmbedding.new }
let(:admin_embedding_host_form_page) { PageObjects::Pages::AdminEmbeddingHostForm.new }
let(:admin_embedding_posts_and_topics_page) do
PageObjects::Pages::AdminEmbeddingPostsAndTopics.new
end
find("td.editing-controls .btn.btn-primary").click
it "allows admin to add, edit and delete embeddable hosts" do
admin_embedding_page.visit
expect(page).not_to have_css(".admin-embedding-index__code")
admin_embedding_page.click_add_host
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
expect(page).to have_content("awesome-discourse-site.local")
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("#{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
find('input[placeholder="example.com"]').set("updated-example.com")
find('input[placeholder="/blog/.*"]').set("/updated-blog/.*")
admin_embedding_host_form_page.fill_in_allowed_hosts("updated-example.com")
admin_embedding_host_form_page.fill_in_path_allow_list("/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-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

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