2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00
discourse/app/assets/javascripts/admin/addon/controllers/admin-customize-themes-show-index.js
Martin Brennan 19af83d39e
FEATURE: Themeable site settings (#32233)
This commit introduces the concept of themeable site settings,
which is a new tool for theme authors that lives alongside theme
modifiers and theme settings. Here is a quick summary:

* Theme settings - These are custom settings used to control UI and functionality within your theme or component and provide configuration options. These cannot change core Discourse functionality.
* Theme modifiers - Allows a theme or a component to modify selected server-side functionality of core Discourse as an alternative to building a plugin.
* Themeable site settings (new) - Allows a theme (not components) to override a small subset of core site settings, which generally control parts of the UI and other minor functionality. This allows themes to have a greater control over the full site experience.

Themeable site settings will be shown for all themes, whether the theme
changes
the value or not, and have a similar UI to custom theme settings.

We are also introducing a new page at
`/admin/config/theme-site-settings` that
allows admins to see all possible themeable site settings, and which
themes
are changing the value from the default.

### Configuration

Theme authors can configure initial values themeable site settings using
a section in the `about.json` file like so:

```json
"theme_site_settings": {
  "search_experience": "search_field"
}
```

These values will not change when the theme updates, because we cannot
know if admins have manually changed them.

### Limitations

Themeable site settings are only really intended to control elements of
the UI, and when retrieving their value we require a theme ID, so these
limitations apply:

- Themeable site settings cannot be used in Sidekiq jobs
- Themeable site settings cannot be used in markdown rules
- Themeable site settings will be cached separately to client site
settings using theme ID as a key
- Themeable site settings will override keys on the `siteSettings`
service on the client using the application preloader
- `SiteSetting.client_settings_json` will not include themeable site
settings, instead you can call `SiteSetting.theme_site_settings_json`
with a theme ID

### Initial settings

There are only two site settings that will be themeable to begin with:

* `enable_welcome_banner`
* `search_experience`

And our new Horizon theme will take advantage of both. Over time, more
settings that control elements of the UI will be exposed this way.
2025-07-16 11:00:21 +10:00

442 lines
12 KiB
JavaScript

import Controller from "@ember/controller";
import { action } from "@ember/object";
import {
empty,
filterBy,
mapBy,
notEmpty,
readOnly,
} from "@ember/object/computed";
import { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { url } from "discourse/lib/computed";
import discourseComputed from "discourse/lib/decorators";
import { makeArray } from "discourse/lib/helpers";
import { i18n } from "discourse-i18n";
import ThemeSettingsEditor from "admin/components/theme-settings-editor";
import SiteSetting from "admin/models/site-setting";
import { COMPONENTS, THEMES } from "admin/models/theme";
import ThemeSettings from "admin/models/theme-settings";
import ThemeUploadAddModal from "../components/theme-upload-add";
const THEME_UPLOAD_VAR = 2;
export default class AdminCustomizeThemesShowIndexController extends Controller {
@service dialog;
@service router;
@service siteSettings;
@service modal;
editRouteName = "adminCustomizeThemes.edit";
@url("model.id", "/admin/customize/themes/%@/export") downloadUrl;
@url("model.id", "/admin/themes/%@/preview") previewUrl;
@url("model.id", "model.locale", "/admin/themes/%@/translations/%@")
getTranslationsUrl;
@empty("selectedChildThemeId") addButtonDisabled;
@mapBy("model.parentThemes", "name") parentThemesNames;
@filterBy("allThemes", "component", false) availableParentThemes;
@filterBy("availableParentThemes", "isActive") availableActiveParentThemes;
@mapBy("availableParentThemes", "name") availableThemesNames;
@mapBy("availableActiveParentThemes", "name") availableActiveThemesNames;
@filterBy("availableChildThemes", "hasParents") availableActiveChildThemes;
@mapBy("availableChildThemes", "name") availableComponentsNames;
@mapBy("availableActiveChildThemes", "name") availableActiveComponentsNames;
@mapBy("model.childThemes", "name") childThemesNames;
@filterBy("model.theme_fields", "target", "extra_js") extraFiles;
@notEmpty("settings") hasSettings;
@notEmpty("translations") hasTranslations;
@notEmpty("model.themeable_site_settings") hasThemeableSiteSettings;
@readOnly("model.settings") settings;
@readOnly("model.themeable_site_settings") themeSiteSettings;
@discourseComputed("model.component", "model.remote_theme")
showCheckboxes() {
return !this.model.component || this.model.remote_theme;
}
@discourseComputed("model.editedFields")
editedFieldsFormatted() {
const descriptions = [];
["common", "desktop", "mobile"].forEach((target) => {
const fields = this.editedFieldsForTarget(target);
if (fields.length < 1) {
return;
}
let resultString = i18n("admin.customize.theme." + target);
const formattedFields = fields
.map((f) => i18n("admin.customize.theme." + f.name + ".text"))
.join(" , ");
resultString += `: ${formattedFields}`;
descriptions.push(resultString);
});
return descriptions;
}
@discourseComputed("colorSchemeId", "model.color_scheme_id")
colorSchemeChanged(colorSchemeId, existingId) {
colorSchemeId = colorSchemeId === null ? null : parseInt(colorSchemeId, 10);
return colorSchemeId !== existingId;
}
@discourseComputed("availableChildThemes", "model.childThemes.[]", "model")
selectableChildThemes(available, childThemes) {
if (available) {
const themes = !childThemes
? available
: available.filter((theme) => !childThemes.includes(theme));
return themes.length === 0 ? null : themes;
}
}
@discourseComputed("model.parentThemes.[]")
relativesSelectorSettingsForComponent() {
return SiteSetting.create({
list_type: "compact",
type: "list",
preview: null,
allow_any: false,
setting: "parent_theme_ids",
label: i18n("admin.customize.theme.component_on_themes"),
choices: this.availableThemesNames,
default: this.parentThemesNames.join("|"),
value: this.parentThemesNames.join("|"),
defaultValues: this.availableActiveThemesNames.join("|"),
allThemes: this.allThemes,
setDefaultValuesLabel: i18n("admin.customize.theme.add_all_themes"),
});
}
@discourseComputed("model.childThemesNames.[]")
relativesSelectorSettingsForTheme() {
return SiteSetting.create({
list_type: "compact",
type: "list",
preview: null,
allow_any: false,
setting: "child_theme_ids",
label: i18n("admin.customize.theme.included_components"),
choices: this.availableComponentsNames,
default: this.childThemesNames.join("|"),
value: this.childThemesNames.join("|"),
defaultValues: this.availableActiveComponentsNames.join("|"),
allThemes: this.allThemes,
setDefaultValuesLabel: i18n("admin.customize.theme.add_all"),
});
}
@discourseComputed("allThemes", "model.component", "model")
availableChildThemes(allThemes) {
if (!this.get("model.component")) {
const themeId = this.get("model.id");
return allThemes.filter(
(theme) => theme.get("id") !== themeId && theme.get("component")
);
}
}
@discourseComputed("model.component")
convertKey(component) {
const type = component ? "component" : "theme";
return `admin.customize.theme.convert_${type}`;
}
@discourseComputed("model.component")
convertIcon(component) {
return component ? "cube" : "";
}
@discourseComputed("model.component")
convertTooltip(component) {
const type = component ? "component" : "theme";
return `admin.customize.theme.convert_${type}_tooltip`;
}
@discourseComputed("model.translations")
translations(translations) {
return translations.map((setting) =>
ThemeSettings.create({ ...setting, textarea: true })
);
}
@discourseComputed(
"model.remote_theme.local_version",
"model.remote_theme.remote_version",
"model.remote_theme.commits_behind"
)
hasOverwrittenHistory(localVersion, remoteVersion, commitsBehind) {
return localVersion !== remoteVersion && commitsBehind === -1;
}
@discourseComputed("model.remoteError", "updatingRemote")
showRemoteError(errorMessage, updating) {
return errorMessage && !updating;
}
editedFieldsForTarget(target) {
return this.get("model.editedFields").filter(
(field) => field.target === target
);
}
commitSwitchType() {
const model = this.model;
const newValue = !model.get("component");
model.set("component", newValue);
if (newValue) {
this.set("parentController.currentTab", COMPONENTS);
} else {
this.set("parentController.currentTab", THEMES);
}
model
.saveChanges("component")
.then(() => {
this.set("colorSchemeId", null);
model.setProperties({
default: false,
color_scheme_id: null,
user_selectable: false,
child_themes: [],
childThemes: [],
});
this.get("parentController.model.content").forEach((theme) => {
const children = makeArray(theme.get("childThemes"));
const rawChildren = makeArray(theme.get("child_themes"));
const index = children ? children.indexOf(model) : -1;
if (index > -1) {
children.splice(index, 1);
rawChildren.splice(index, 1);
theme.setProperties({
childThemes: children,
child_themes: rawChildren,
});
}
});
})
.catch(popupAjaxError);
}
transitionToEditRoute() {
this.router.transitionTo(
this.editRouteName,
this.get("model.id"),
"common",
"scss"
);
}
@discourseComputed("model.user.id", "model.default")
showConvert(userId, defaultTheme) {
return userId > 0 && !defaultTheme;
}
@action
refreshModel() {
this.send("routeRefreshModel");
}
@action
updateToLatest() {
this.set("updatingRemote", true);
this.model
.updateToLatest()
.catch(popupAjaxError)
.finally(() => {
this.set("updatingRemote", false);
});
}
@action
checkForThemeUpdates() {
this.set("updatingRemote", true);
this.model
.checkForUpdates()
.catch(popupAjaxError)
.finally(() => {
this.set("updatingRemote", false);
});
}
@action
addUploadModal() {
this.modal.show(ThemeUploadAddModal, {
model: {
themeFields: this.model.theme_fields,
addUpload: this.addUpload,
},
});
}
@action
addUpload(info) {
let model = this.model;
model.setField("common", info.name, "", info.upload_id, THEME_UPLOAD_VAR);
model.saveChanges("theme_fields").catch((e) => popupAjaxError(e));
}
get availableLocales() {
return JSON.parse(this.siteSettings.available_locales);
}
get locale() {
return (
this.get("model.locale") ||
this.userLocale ||
this.siteSettings.default_locale
);
}
@action
updateLocale(value) {
this.set("model.loadingTranslations", true);
this.set("model.locale", value);
ajax(this.getTranslationsUrl).then(({ translations }) => {
this.set("model.translations", translations);
this.set("model.loadingTranslations", false);
});
}
@action
cancelChangeScheme() {
this.set("colorSchemeId", this.get("model.color_scheme_id"));
}
@action
changeScheme() {
let schemeId = this.colorSchemeId;
this.set(
"model.color_scheme_id",
schemeId === null ? null : parseInt(schemeId, 10)
);
this.model.saveChanges("color_scheme_id");
}
@action
editTheme() {
if (this.get("model.remote_theme.is_git")) {
this.dialog.confirm({
message: i18n("admin.customize.theme.edit_confirm"),
didConfirm: () => this.transitionToEditRoute(),
});
} else {
this.transitionToEditRoute();
}
}
@action
applyDefault() {
const model = this.model;
model.saveChanges("default").then(() => {
if (model.get("default")) {
this.allThemes.forEach((theme) => {
if (theme !== model && theme.get("default")) {
theme.set("default", false);
}
});
}
});
}
@action
applyUserSelectable() {
this.model.saveChanges("user_selectable");
}
@action
applyAutoUpdateable() {
this.model.saveChanges("auto_update");
}
@action
addChildTheme() {
let themeId = parseInt(this.selectedChildThemeId, 10);
let theme = this.allThemes.findBy("id", themeId);
this.model.addChildTheme(theme).then(() => this.store.findAll("theme"));
}
@action
removeUpload(upload) {
return this.dialog.deleteConfirm({
title: i18n("admin.customize.theme.delete_upload_confirm"),
didConfirm: () => this.model.removeField(upload),
});
}
@action
removeChildTheme(theme) {
this.model.removeChildTheme(theme).then(() => this.store.findAll("theme"));
}
@action
destroyTheme() {
return this.dialog.deleteConfirm({
title: i18n("admin.customize.delete_confirm", {
theme_name: this.get("model.name"),
}),
didConfirm: () => {
const model = this.model;
model.setProperties({ recentlyInstalled: false });
model.destroyRecord().then(() => {
this.allThemes.removeObject(model);
this.router.transitionTo("adminConfig.customize.themes");
});
},
});
}
@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")
? this.get("model.parentThemes")
: this.get("model.childThemes");
let message = i18n(`${this.convertKey}_alert_generic`);
if (relatives && relatives.length > 0) {
message = i18n(`${this.convertKey}_alert`, {
relatives: relatives.map((relative) => relative.get("name")).join(", "),
});
}
return this.dialog.yesNoConfirm({
message,
didConfirm: () => this.commitSwitchType(),
});
}
@action
enableComponent() {
this.model.set("enabled", true);
this.model
.saveChanges("enabled")
.catch(() => this.model.set("enabled", false));
}
@action
disableComponent() {
this.model.set("enabled", false);
this.model
.saveChanges("enabled")
.catch(() => this.model.set("enabled", true));
}
@action
editColorScheme() {
this.router.transitionTo("adminCustomize.colors.show", this.colorSchemeId);
}
}