From 80f5018924e689e40daaedcb5b1896978de87378 Mon Sep 17 00:00:00 2001 From: marstall Date: Thu, 27 Jul 2023 13:48:59 -0400 Subject: [PATCH] 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 --- .../components/theme-settings-editor.hbs | 23 +++ .../addon/components/theme-settings-editor.js | 191 ++++++++++++++++++ .../admin-customize-themes-show.js | 16 ++ .../addon/routes/admin-customize-themes.js | 5 + .../addon/templates/customize-themes-show.hbs | 10 +- .../admin-theme-settings-editor-test.js | 141 +++++++++++++ .../admin-customize-themes-show-test.js | 36 ++++ .../stylesheets/common/admin/customize.scss | 22 ++ config/locales/client.en.yml | 7 + 9 files changed, 450 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/admin/addon/components/theme-settings-editor.hbs create mode 100644 app/assets/javascripts/admin/addon/components/theme-settings-editor.js create mode 100644 app/assets/javascripts/discourse/tests/integration/components/admin-theme-settings-editor-test.js 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"