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:
parent
a44378a1b6
commit
80f5018924
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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}}
|
||||
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue