mirror of
https://github.com/discourse/discourse.git
synced 2025-05-29 01:31:35 +08:00
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:
@ -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 { makeArray } from "discourse-common/lib/helpers";
|
||||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||||
import { url } from "discourse/lib/computed";
|
import { url } from "discourse/lib/computed";
|
||||||
|
import ThemeSettingsEditor from "admin/components/theme-settings-editor";
|
||||||
import ThemeUploadAddModal from "../components/theme-upload-add";
|
import ThemeUploadAddModal from "../components/theme-upload-add";
|
||||||
|
|
||||||
const THEME_UPLOAD_VAR = 2;
|
const THEME_UPLOAD_VAR = 2;
|
||||||
@ -251,6 +252,11 @@ export default class AdminCustomizeThemesShowController extends Controller {
|
|||||||
return userId > 0 && !defaultTheme;
|
return userId > 0 && !defaultTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
refreshModel() {
|
||||||
|
this.send("routeRefreshModel");
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updateToLatest() {
|
updateToLatest() {
|
||||||
this.set("updatingRemote", true);
|
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
|
@action
|
||||||
switchType() {
|
switchType() {
|
||||||
const relatives = this.get("model.component")
|
const relatives = this.get("model.component")
|
||||||
|
@ -18,6 +18,11 @@ export default class AdminCustomizeThemesRoute extends Route {
|
|||||||
return this.store.findAll("theme");
|
return this.store.findAll("theme");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
routeRefreshModel() {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
setupController(controller, model) {
|
setupController(controller, model) {
|
||||||
super.setupController(controller, model);
|
super.setupController(controller, model);
|
||||||
controller.set("editingTheme", false);
|
controller.set("editingTheme", false);
|
||||||
|
@ -496,14 +496,22 @@
|
|||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/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
|
<DButton
|
||||||
@action={{action "destroyTheme"}}
|
@action={{action "destroyTheme"}}
|
||||||
@label="admin.customize.delete"
|
@label="admin.customize.delete"
|
||||||
@icon="trash-alt"
|
@icon="trash-alt"
|
||||||
@class="btn-danger"
|
@class="btn-danger"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/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"
|
"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-customize.admin-customize-themes {
|
||||||
.admin-container {
|
.admin-container {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -5063,6 +5063,11 @@ en:
|
|||||||
title: "Customize"
|
title: "Customize"
|
||||||
preview: "preview"
|
preview: "preview"
|
||||||
explain_preview: "See the site with this theme enabled"
|
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"
|
save: "Save"
|
||||||
new: "New"
|
new: "New"
|
||||||
new_style: "New Style"
|
new_style: "New Style"
|
||||||
@ -5101,6 +5106,7 @@ en:
|
|||||||
create: "Create"
|
create: "Create"
|
||||||
create_type: "Type"
|
create_type: "Type"
|
||||||
create_name: "Name"
|
create_name: "Name"
|
||||||
|
save: "Save"
|
||||||
long_title: "Amend colors, CSS and HTML contents of your site"
|
long_title: "Amend colors, CSS and HTML contents of your site"
|
||||||
edit: "Edit"
|
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."
|
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_upload: "Export theme to view these files."
|
||||||
extra_files_remote: "Export theme or check the git repository to view these files."
|
extra_files_remote: "Export theme or check the git repository to view these files."
|
||||||
preview: "Preview"
|
preview: "Preview"
|
||||||
|
settings_editor: "Settings Editor"
|
||||||
show_advanced: "Show advanced fields"
|
show_advanced: "Show advanced fields"
|
||||||
hide_advanced: "Hide advanced fields"
|
hide_advanced: "Hide advanced fields"
|
||||||
hide_unused_fields: "Hide unused fields"
|
hide_unused_fields: "Hide unused fields"
|
||||||
|
Reference in New Issue
Block a user