diff --git a/app/assets/javascripts/admin/addon/components/theme-settings-editor.hbs b/app/assets/javascripts/admin/addon/components/theme-settings-editor.hbs
new file mode 100644
index 00000000000..d3884cfdb5f
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/components/theme-settings-editor.hbs
@@ -0,0 +1,23 @@
+
+
+
+
+ {{#each this.errors as |error|}}
+
+ {{d-icon "times"}}
+ {{error.setting}}:
+ {{error.errorMessage}}
+
+ {{/each}}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/admin/addon/components/theme-settings-editor.js b/app/assets/javascripts/admin/addon/components/theme-settings-editor.js
new file mode 100644
index 00000000000..17993846ace
--- /dev/null
+++ b/app/assets/javascripts/admin/addon/components/theme-settings-editor.js
@@ -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,
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js
index 93f8c56db4b..b37756645c4 100644
--- a/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js
+++ b/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show.js
@@ -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")
diff --git a/app/assets/javascripts/admin/addon/routes/admin-customize-themes.js b/app/assets/javascripts/admin/addon/routes/admin-customize-themes.js
index 7f323281ff4..04f1df28105 100644
--- a/app/assets/javascripts/admin/addon/routes/admin-customize-themes.js
+++ b/app/assets/javascripts/admin/addon/routes/admin-customize-themes.js
@@ -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);
diff --git a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs
index 8f3682f611f..3de57309c53 100644
--- a/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs
+++ b/app/assets/javascripts/admin/addon/templates/customize-themes-show.hbs
@@ -496,14 +496,22 @@
/>
{{/if}}
{{/if}}
+ {{#if this.hasSettings}}
+
+ {{/if}}
-
{{/if}}
diff --git a/app/assets/javascripts/discourse/tests/integration/components/admin-theme-settings-editor-test.js b/app/assets/javascripts/discourse/tests/integration/components/admin-theme-settings-editor-test.js
new file mode 100644
index 00000000000..48e42efa7d2
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/integration/components/admin-theme-settings-editor-test.js
@@ -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``);
+ 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");
+ });
+ }
+);
diff --git a/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js b/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js
index 8de26d68855..9f929804391 100644
--- a/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js
+++ b/app/assets/javascripts/discourse/tests/unit/controllers/admin-customize-themes-show-test.js
@@ -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"
+ );
+ });
});
diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss
index 925ff47e8cb..39b75d4b0b6 100644
--- a/app/assets/stylesheets/common/admin/customize.scss
+++ b/app/assets/stylesheets/common/admin/customize.scss
@@ -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;
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index ef0a16fe7ba..377ac3ff07c 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -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"