FEATURE: JSON editor for theme settings (#21647)

provide the ability to edit theme settings in the json editor, and also copy them as a text file so they can be pasted into another instance.

Reference: /t/65023
This commit is contained in:
marstall 2023-07-27 13:48:59 -04:00 committed by GitHub
parent a44378a1b6
commit 80f5018924
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 450 additions and 1 deletions

View File

@ -0,0 +1,23 @@
<div class="settings-editor">
<div>
<AceEditor @mode="html" @content={{this.editedContent}} />
{{#each this.errors as |error|}}
<div class="validation-error">
{{d-icon "times"}}
<b>{{error.setting}}</b>:
{{error.errorMessage}}
</div>
{{/each}}
</div>
<div class="buttons">
<DButton
@action={{this.save}}
id="save"
class="btn-primary save"
@disabled={{this.saveButtonDisabled}}
@translatedLabel={{i18n "admin.customize.theme.save"}}
/>
</div>
</div>

View File

@ -0,0 +1,191 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import { inject as service } from "@ember/service";
import I18n from "I18n";
export default class ThemeSettingsEditor extends Component {
@service dialog;
@tracked editedContent = JSON.stringify(
this.condensedThemeSettings,
null,
"\t"
);
@tracked errors = [];
@tracked saving = false;
// we need to store the controller being passed in so that when we
// call `save` we have not lost context of the argument
customizeThemeShowController = this.args.model?.controller;
get saveButtonDisabled() {
return !this.documentChanged || this.saving;
}
get documentChanged() {
try {
if (!this.editedContent) {
return false;
}
const editedContentString = JSON.stringify(
JSON.parse(this.editedContent)
);
const themeSettingsString = JSON.stringify(this.condensedThemeSettings);
if (editedContentString.localeCompare(themeSettingsString) !== 0) {
this.errors = [];
return true;
} else {
return false;
}
} catch {
return true;
}
}
get theme() {
return this.args.model?.model;
}
get condensedThemeSettings() {
if (!this.theme) {
return null;
}
return this.theme.settings.map((setting) => ({
setting: setting.setting,
value: setting.value,
}));
}
// validates the following:
// each setting must have a 'setting' and a 'value' key and no other keys
validateSettingsKeys(settings) {
return settings.reduce((acc, setting) => {
if (!acc) {
return acc;
}
if (!("setting" in setting)) {
// must have a setting key
return false;
}
if (!("value" in setting)) {
// must have a value key
return false;
}
if (Object.keys(setting).length > 2) {
// at this point it's verified to have setting and value key - but must have no other keys
return false;
}
return true;
}, true);
}
@action
async save() {
this.saving = true;
this.errors = [];
this.success = "";
if (!this.editedContent) {
// no changes.
return;
}
let newSettings = "";
try {
newSettings = JSON.parse(this.editedContent);
} catch (e) {
this.errors = [
...this.errors,
{
setting: I18n.t("admin.customize.syntax_error"),
errorMessage: e.message,
},
];
this.saving = false;
return;
}
if (!this.validateSettingsKeys(newSettings)) {
this.errors = [
...this.errors,
{
setting: I18n.t("admin.customize.syntax_error"),
errorMessage: I18n.t("admin.customize.validation_settings_keys"),
},
];
this.saving = false;
return;
}
const originalNames = this.theme
? this.theme.settings.map((setting) => setting.setting)
: [];
const newNames = newSettings.map((setting) => setting.setting);
const deletedNames = originalNames.filter(
(originalName) => !newNames.find((newName) => newName === originalName)
);
const addedNames = newNames.filter(
(newName) =>
!originalNames.find((originalName) => originalName === newName)
);
if (deletedNames.length) {
this.errors = [
...this.errors,
{
setting: deletedNames.join(", "),
errorMessage: I18n.t("admin.customize.validation_settings_deleted"),
},
];
}
if (addedNames.length) {
this.errors = [
...this.errors,
{
setting: addedNames.join(","),
errorMessage: I18n.t("admin.customize.validation_settings_added"),
},
];
}
if (this.errors.length) {
this.saving = false;
return;
}
const changedSettings = newSettings.filter((newSetting) => {
const originalSetting = this.theme.settings.find(
(_originalSetting) => _originalSetting.setting === newSetting.setting
);
return originalSetting.value !== newSetting.value;
});
for (let setting of changedSettings) {
try {
await this.saveSetting(this.theme.id, setting);
} catch (err) {
const errorObjects = JSON.parse(err.jqXHR.responseText).errors.map(
(error) => ({
setting: setting.setting,
errorMessage: error,
})
);
this.errors = [...this.errors, ...errorObjects];
}
}
if (this.errors.length === 0) {
this.editedContent = null;
}
this.saving = false;
this.dialog.cancel();
this.customizeThemeShowController.send("routeRefreshModel");
}
async saveSetting(themeId, setting) {
const updateUrl = `/admin/themes/${themeId}/setting`;
return await ajax(updateUrl, {
type: "PUT",
data: {
name: setting.setting,
value: setting.value,
},
});
}
}

View File

@ -15,6 +15,7 @@ import discourseComputed from "discourse-common/utils/decorators";
import { makeArray } from "discourse-common/lib/helpers";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { url } from "discourse/lib/computed";
import ThemeSettingsEditor from "admin/components/theme-settings-editor";
import ThemeUploadAddModal from "../components/theme-upload-add";
const THEME_UPLOAD_VAR = 2;
@ -251,6 +252,11 @@ export default class AdminCustomizeThemesShowController extends Controller {
return userId > 0 && !defaultTheme;
}
@action
refreshModel() {
this.send("routeRefreshModel");
}
@action
updateToLatest() {
this.set("updatingRemote", true);
@ -396,6 +402,16 @@ export default class AdminCustomizeThemesShowController extends Controller {
});
}
@action
showThemeSettingsEditor() {
this.dialog.alert({
title: "Edit Settings",
bodyComponent: ThemeSettingsEditor,
bodyComponentModel: { model: this.model, controller: this },
class: "theme-settings-editor-dialog",
});
}
@action
switchType() {
const relatives = this.get("model.component")

View File

@ -18,6 +18,11 @@ export default class AdminCustomizeThemesRoute extends Route {
return this.store.findAll("theme");
}
@action
routeRefreshModel() {
this.refresh();
}
setupController(controller, model) {
super.setupController(controller, model);
controller.set("editingTheme", false);

View File

@ -496,14 +496,22 @@
/>
{{/if}}
{{/if}}
{{#if this.hasSettings}}
<DButton
@action={{this.showThemeSettingsEditor}}
@label="admin.customize.theme.settings_editor"
@icon="pen"
@class="btn-default btn-normal"
@title="admin.customize.theme.settings_editor"
/>
{{/if}}
<DButton
@action={{action "destroyTheme"}}
@label="admin.customize.delete"
@icon="trash-alt"
@class="btn-danger"
/>
</div>
{{/if}}

View File

@ -0,0 +1,141 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
/*
example valid content for ace editor:
[
{
"setting": "whitelisted_fruits",
"value": "uudu"
},
{
"setting": "favorite_fruit",
"value": "orange"
},
{
"setting": "year",
"value": 1992
},
{
"setting": "banner_links",
"value": "[{\"icon\":\"info-circle\",\"text\":\"about this site\",\"url\":\"/faq\"}, {\"icon\":\"users\",\"text\":\"meet our staff\",\"url\":\"/about\"}, {\"icon\":\"star\",\"text\":\"your preferences\",\"url\":\"/my/preferences\"}]"
}
]
*/
function glimmerComponent(owner, componentName, args = {}) {
const { class: componentClass } = owner.factoryFor(
`component:${componentName}`
);
let componentManager = owner.lookup("component-manager:glimmer");
let component = componentManager.createComponent(componentClass, {
named: args,
});
return component;
}
module(
"Integration | Component | admin-theme-settings-editor",
function (hooks) {
setupRenderingTest(hooks);
let model;
test("renders passed json model object into string in the ace editor", async function (assert) {
await render(hbs`<ThemeSettingsEditor @model={{hash
model=(hash
settings=(array
(hash
setting='setting1'
value='value1')
(hash
setting='setting2'
value='value2')
)
)
}} />`);
const lines = document.querySelectorAll(".ace_line");
assert.strictEqual(lines[0].innerHTML, "[");
});
test("input is valid json", async function (assert) {
const component = glimmerComponent(this.owner, "theme-settings-editor", {
model: [],
});
component.editedContent = "foo";
component.save();
assert.strictEqual(component.errors[0].setting, "Syntax Error");
});
test("'setting' key is present for each setting", async function (assert) {
const component = glimmerComponent(this.owner, "theme-settings-editor", {
model: [],
});
component.editedContent = JSON.stringify([{ value: "value1" }]);
component.save();
assert.strictEqual(component.errors[0].setting, "Syntax Error");
});
test("'value' key is present for each setting", async function (assert) {
const component = glimmerComponent(this.owner, "theme-settings-editor", {
model: [],
});
component.editedContent = JSON.stringify([{ setting: "setting1" }]);
component.save();
assert.strictEqual(component.errors[0].setting, "Syntax Error");
});
test("only 'setting' and 'value' keys are present, no others", async function (assert) {
const component = glimmerComponent(this.owner, "theme-settings-editor", {
model: [],
});
component.editedContent = JSON.stringify([{ otherkey: "otherkey1" }]);
component.save();
assert.strictEqual(component.errors[0].setting, "Syntax Error");
});
test("no settings are deleted", async function (assert) {
model = {
model: {
settings: [
{ setting: "foo", value: "foo" },
{ setting: "bar", value: "bar" },
],
},
};
const component = glimmerComponent(this.owner, "theme-settings-editor", {
model,
});
component.editedContent = JSON.stringify([
{ setting: "bar", value: "bar" },
]);
component.save();
assert.strictEqual(component.errors[0].setting, "foo");
});
test("no settings are added", async function (assert) {
model = {
model: {
settings: [{ setting: "bar", value: "bar" }],
},
};
const component = glimmerComponent(this.owner, "theme-settings-editor", {
model,
});
component.editedContent = JSON.stringify([
{ setting: "foo", value: "foo" },
{ setting: "bar", value: "bar" },
]);
component.save();
assert.strictEqual(component.errors[0].setting, "foo");
});
}
);

View File

@ -50,4 +50,40 @@ module("Unit | Controller | admin-customize-themes-show", function (hooks) {
"returns theme's repo URL to branch"
);
});
test("displays settings editor button with settings", function (assert) {
const theme = Theme.create({
id: 2,
default: true,
name: "default",
settings: [{}],
});
const controller = this.owner.lookup(
"controller:admin-customize-themes-show"
);
controller.setProperties({ model: theme });
assert.deepEqual(
controller.hasSettings,
true,
"sets the hasSettings property to true with settings"
);
});
test("hides settings editor button with no settings", function (assert) {
const theme = Theme.create({
id: 2,
default: true,
name: "default",
settings: [],
});
const controller = this.owner.lookup(
"controller:admin-customize-themes-show"
);
controller.setProperties({ model: theme });
assert.deepEqual(
controller.hasSettings,
false,
"sets the hasSettings property to true with settings"
);
});
});

View File

@ -23,6 +23,28 @@
}
}
.settings-editor {
.ace-wrapper {
position: relative;
width: 100%;
height: 100%;
min-height: 300px;
.ace_editor {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
}
}
.theme-settings-editor-dialog {
.dialog-footer {
display: none;
}
}
.admin-customize.admin-customize-themes {
.admin-container {
padding: 0;

View File

@ -5063,6 +5063,11 @@ en:
title: "Customize"
preview: "preview"
explain_preview: "See the site with this theme enabled"
syntax_error: "Syntax Error"
settings_editor: "Settings Editor"
validation_settings_keys: "Each item must have only a 'setting' key and a 'value' key."
validation_settings_deleted: "These settings were deleted. Please restore them and try again."
validation_settings_added: "These settings were added. Please remove them and try again."
save: "Save"
new: "New"
new_style: "New Style"
@ -5101,6 +5106,7 @@ en:
create: "Create"
create_type: "Type"
create_name: "Name"
save: "Save"
long_title: "Amend colors, CSS and HTML contents of your site"
edit: "Edit"
edit_confirm: "This is a remote theme, if you edit CSS/HTML your changes will be erased next time you update the theme."
@ -5116,6 +5122,7 @@ en:
extra_files_upload: "Export theme to view these files."
extra_files_remote: "Export theme or check the git repository to view these files."
preview: "Preview"
settings_editor: "Settings Editor"
show_advanced: "Show advanced fields"
hide_advanced: "Hide advanced fields"
hide_unused_fields: "Hide unused fields"