DEV: Allow typed objects theme settings to be saved via settings editor (#26100)

Why this change?

On the `/admin/customize/themes/<:id>` route, we allow admins to edit
all settings via a settings editor. Prior to this change, trying to edit
and save a typed objects theme settings will result in an error on the
server.
This commit is contained in:
Alan Guo Xiang Tan 2024-03-11 08:42:12 +08:00 committed by GitHub
parent 17a60be189
commit 8d4f405da4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 171 additions and 34 deletions

View File

@ -1,4 +1,5 @@
import RestAdapter from "discourse/adapters/rest"; import RestAdapter from "discourse/adapters/rest";
import ThemeSettings from "admin/models/theme-settings";
export default class Theme extends RestAdapter { export default class Theme extends RestAdapter {
jsonMode = true; jsonMode = true;
@ -8,9 +9,11 @@ export default class Theme extends RestAdapter {
afterFindAll(results) { afterFindAll(results) {
let map = {}; let map = {};
results.forEach((theme) => { results.forEach((theme) => {
map[theme.id] = theme; map[theme.id] = theme;
}); });
results.forEach((theme) => { results.forEach((theme) => {
let mapped = theme.get("child_themes") || []; let mapped = theme.get("child_themes") || [];
mapped = mapped.map((t) => map[t.id]); mapped = mapped.map((t) => map[t.id]);
@ -19,7 +22,15 @@ export default class Theme extends RestAdapter {
let mappedParents = theme.get("parent_themes") || []; let mappedParents = theme.get("parent_themes") || [];
mappedParents = mappedParents.map((t) => map[t.id]); mappedParents = mappedParents.map((t) => map[t.id]);
theme.set("parentThemes", mappedParents); theme.set("parentThemes", mappedParents);
if (theme.settings) {
theme.set(
"settings",
theme.settings.map((setting) => ThemeSettings.create(setting))
);
}
}); });
return results; return results;
} }
} }

View File

@ -1,17 +1,10 @@
import { ajax } from "discourse/lib/ajax";
import { url } from "discourse/lib/computed";
import SiteSettingComponent from "./site-setting"; import SiteSettingComponent from "./site-setting";
export default class extends SiteSettingComponent { export default class extends SiteSettingComponent {
@url("model.id", "/admin/themes/%@/setting") updateUrl;
_save() { _save() {
return ajax(this.updateUrl, { return this.setting.updateSetting(
type: "PUT", this.model.id,
data: { this.get("buffered.value")
name: this.setting.setting, );
value: this.get("buffered.value"),
},
});
} }
} }

View File

@ -2,7 +2,6 @@ import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
export default class ThemeSettingsEditor extends Component { export default class ThemeSettingsEditor extends Component {
@ -52,6 +51,7 @@ export default class ThemeSettingsEditor extends Component {
if (!this.theme) { if (!this.theme) {
return null; return null;
} }
return this.theme.settings.map((setting) => ({ return this.theme.settings.map((setting) => ({
setting: setting.setting, setting: setting.setting,
value: setting.value, value: setting.value,
@ -86,11 +86,14 @@ export default class ThemeSettingsEditor extends Component {
this.saving = true; this.saving = true;
this.errors = []; this.errors = [];
this.success = ""; this.success = "";
if (!this.editedContent) { if (!this.editedContent) {
// no changes. // no changes.
return; return;
} }
let newSettings = ""; let newSettings = "";
try { try {
newSettings = JSON.parse(this.editedContent); newSettings = JSON.parse(this.editedContent);
} catch (e) { } catch (e) {
@ -104,6 +107,7 @@ export default class ThemeSettingsEditor extends Component {
this.saving = false; this.saving = false;
return; return;
} }
if (!this.validateSettingsKeys(newSettings)) { if (!this.validateSettingsKeys(newSettings)) {
this.errors = [ this.errors = [
...this.errors, ...this.errors,
@ -119,14 +123,17 @@ export default class ThemeSettingsEditor extends Component {
const originalNames = this.theme const originalNames = this.theme
? this.theme.settings.map((setting) => setting.setting) ? this.theme.settings.map((setting) => setting.setting)
: []; : [];
const newNames = newSettings.map((setting) => setting.setting); const newNames = newSettings.map((setting) => setting.setting);
const deletedNames = originalNames.filter( const deletedNames = originalNames.filter(
(originalName) => !newNames.find((newName) => newName === originalName) (originalName) => !newNames.find((newName) => newName === originalName)
); );
const addedNames = newNames.filter( const addedNames = newNames.filter(
(newName) => (newName) =>
!originalNames.find((originalName) => originalName === newName) !originalNames.find((originalName) => originalName === newName)
); );
if (deletedNames.length) { if (deletedNames.length) {
this.errors = [ this.errors = [
...this.errors, ...this.errors,
@ -136,6 +143,7 @@ export default class ThemeSettingsEditor extends Component {
}, },
]; ];
} }
if (addedNames.length) { if (addedNames.length) {
this.errors = [ this.errors = [
...this.errors, ...this.errors,
@ -151,41 +159,49 @@ export default class ThemeSettingsEditor extends Component {
return; return;
} }
const changedSettings = newSettings.filter((newSetting) => { const changedSettings = [];
newSettings.forEach((newSetting) => {
const originalSetting = this.theme.settings.find( const originalSetting = this.theme.settings.find(
(_originalSetting) => _originalSetting.setting === newSetting.setting (_originalSetting) => _originalSetting.setting === newSetting.setting
); );
return originalSetting.value !== newSetting.value;
if (originalSetting.value !== newSetting.value) {
changedSettings.push({
originalSetting,
value: newSetting.value,
}); });
for (let setting of changedSettings) { }
});
for (let changedSetting of changedSettings) {
try { try {
await this.saveSetting(this.theme.id, setting); await this.saveSetting(this.theme.id, changedSetting);
} catch (err) { } catch (err) {
const errorObjects = JSON.parse(err.jqXHR.responseText).errors.map( const errorObjects = JSON.parse(err.jqXHR.responseText).errors.map(
(error) => ({ (error) => ({
setting: setting.setting, setting: changedSetting.originalSetting.setting,
errorMessage: error, errorMessage: error,
}) })
); );
this.errors = [...this.errors, ...errorObjects]; this.errors = [...this.errors, ...errorObjects];
} }
} }
if (this.errors.length === 0) { if (this.errors.length === 0) {
this.editedContent = null; this.editedContent = null;
} }
this.saving = false; this.saving = false;
this.dialog.cancel(); this.dialog.cancel();
this.customizeThemeShowController.send("routeRefreshModel"); this.customizeThemeShowController.send("routeRefreshModel");
} }
async saveSetting(themeId, setting) { async saveSetting(themeId, changedSetting) {
const updateUrl = `/admin/themes/${themeId}/setting`; return await changedSetting.originalSetting.updateSetting(
return await ajax(updateUrl, { themeId,
type: "PUT", changedSetting.value
data: { );
name: setting.setting,
value: setting.value,
},
});
} }
} }

View File

@ -6,6 +6,7 @@ import {
mapBy, mapBy,
match, match,
notEmpty, notEmpty,
readOnly,
} from "@ember/object/computed"; } from "@ember/object/computed";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
@ -43,6 +44,7 @@ export default class AdminCustomizeThemesShowController extends Controller {
@notEmpty("settings") hasSettings; @notEmpty("settings") hasSettings;
@notEmpty("translations") hasTranslations; @notEmpty("translations") hasTranslations;
@match("model.remote_theme.remote_url", /^http(s)?:\/\//) sourceIsHttp; @match("model.remote_theme.remote_url", /^http(s)?:\/\//) sourceIsHttp;
@readOnly("model.settings") settings;
@discourseComputed("model.component", "model.remote_theme") @discourseComputed("model.component", "model.remote_theme")
showCheckboxes() { showCheckboxes() {
@ -146,11 +148,6 @@ export default class AdminCustomizeThemesShowController extends Controller {
return `admin.customize.theme.convert_${type}_tooltip`; return `admin.customize.theme.convert_${type}_tooltip`;
} }
@discourseComputed("model.settings")
settings(settings) {
return settings.map((setting) => ThemeSettings.create(setting));
}
@discourseComputed("model.translations") @discourseComputed("model.translations")
translations(translations) { translations(translations) {
return translations.map((setting) => return translations.map((setting) =>

View File

@ -1,4 +1,19 @@
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import Setting from "admin/mixins/setting-object"; import Setting from "admin/mixins/setting-object";
export default class ThemeSettings extends EmberObject.extend(Setting) {} export default class ThemeSettings extends EmberObject.extend(Setting) {
updateSetting(themeId, newValue) {
if (this.objects_schema) {
newValue = JSON.stringify(newValue);
}
return ajax(`/admin/themes/${themeId}/setting`, {
type: "PUT",
data: {
name: this.setting,
value: newValue,
},
});
}
}

View File

@ -6,6 +6,7 @@ class ThemeSettingsManager::Objects < ThemeSettingsManager
end end
def value=(objects) def value=(objects)
objects = JSON.parse(objects) if objects.is_a?(::String)
ensure_is_valid_value!(objects) ensure_is_valid_value!(objects)
record = has_record? ? update_record!(json_value: objects) : create_record!(json_value: objects) record = has_record? ? update_record!(json_value: objects) : create_record!(json_value: objects)
theme.reload theme.reload

View File

@ -1040,7 +1040,7 @@ RSpec.describe Admin::ThemesController do
end end
describe "#update_single_setting" do describe "#update_single_setting" do
let(:theme) { Fabricate(:theme) } fab!(:theme)
before do before do
theme.set_field(target: :settings, name: :yaml, value: "bg: red") theme.set_field(target: :settings, name: :yaml, value: "bg: red")
@ -1063,6 +1063,38 @@ RSpec.describe Admin::ThemesController do
expect(user_history.action).to eq(UserHistory.actions[:change_theme_setting]) expect(user_history.action).to eq(UserHistory.actions[:change_theme_setting])
end end
it "should be able to update a theme setting of `objects` typed" do
SiteSetting.experimental_objects_type_for_theme_settings = true
field =
theme.set_field(
target: :settings,
name: "yaml",
value: File.read("#{Rails.root}/spec/fixtures/theme_settings/objects_settings.yaml"),
)
theme.save!
put "/admin/themes/#{theme.id}/setting.json",
params: {
name: "objects_setting",
value: [
{ name: "new_section", links: [{ name: "new link", url: "https://some.url.com" }] },
].to_json,
}
expect(response.status).to eq(200)
expect(theme.settings[:objects_setting].value).to eq(
[
{
"name" => "new_section",
"links" => [{ "name" => "new link", "url" => "https://some.url.com" }],
},
],
)
end
it "should clear a theme setting" do it "should clear a theme setting" do
put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg" } put "/admin/themes/#{theme.id}/setting.json", params: { name: "bg" }
theme.reload theme.reload

View File

@ -110,5 +110,43 @@ describe "Admin Customize Themes", type: :system do
"/admin/customize/themes/#{theme.id}/schema/objects_setting", "/admin/customize/themes/#{theme.id}/schema/objects_setting",
) )
end end
it "allows an admin to edit a theme setting of objects type via the settings editor" do
visit "/admin/customize/themes/#{theme.id}"
theme_settings_editor = admin_customize_themes_page.click_theme_settings_editor_button
theme_settings_editor.fill_in(<<~SETTING)
[
{
"setting": "objects_setting",
"value": [
{
"name": "new section",
"links": [
{
"name": "new link",
"url": "https://example.com"
}
]
}
]
}
]
SETTING
theme_settings_editor.save
try_until_success do
expect(theme.reload.settings[:objects_setting].value).to eq(
[
{
"links" => [{ "name" => "new link", "url" => "https://example.com" }],
"name" => "new section",
},
],
)
end
end
end end
end end

View File

@ -9,6 +9,9 @@ module PageObjects
end end
def fill_input(content) def fill_input(content)
# Clear the input before filling it in because capybara's fill_in method doesn't seem to replace existing content
# unless the content is a blank string.
editor_input.fill_in(with: "")
editor_input.fill_in(with: content) editor_input.fill_in(with: content)
self self
end end
@ -18,7 +21,10 @@ module PageObjects
end end
def editor_input def editor_input
find(".ace-wrapper .ace_text-input", visible: false) find(".ace-wrapper .ace:not(.hidden)", visible: true).find(
".ace_text-input",
visible: false,
)
end end
end end
end end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
module PageObjects
module Components
class AdminThemeSettingsEditor < Base
def fill_in(settings)
editor.fill_input(settings)
self
end
def save
click_button(I18n.t("admin_js.admin.customize.theme.save"))
self
end
private
def editor
@editor ||= within(".settings-editor") { AceEditor.new }
end
end
end
end

View File

@ -42,6 +42,11 @@ module PageObjects
def click_edit_objects_theme_setting_button(setting_name) def click_edit_objects_theme_setting_button(setting_name)
find(".theme-setting[data-setting=\"#{setting_name}\"] .setting-value-edit-button").click find(".theme-setting[data-setting=\"#{setting_name}\"] .setting-value-edit-button").click
end end
def click_theme_settings_editor_button
click_button(I18n.t("admin_js.admin.customize.theme.settings_editor"))
PageObjects::Components::AdminThemeSettingsEditor.new
end
end end
end end
end end