FEATURE: Added settings/translations support to theme editor UI (#7026)

- These advanced fields are hidden behind an 'advanced' button, so will not affect normal use
- The editor has been refactored into a component, and styling cleaned up so menu items do not overlap on small screens
- Styling has been added to indicate which fields are in use for a theme
- Icons have been added to identify which fields have errors
This commit is contained in:
David Taylor 2019-02-19 12:56:01 +00:00 committed by GitHub
parent 0616837a5d
commit 05ee1d1aba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 380 additions and 253 deletions

View File

@ -0,0 +1,96 @@
import { default as computed } from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
@computed("theme.targets", "onlyOverridden", "showAdvanced")
visibleTargets(targets, onlyOverridden, showAdvanced) {
return targets.filter(target => {
if (target.advanced && !showAdvanced) {
return false;
}
if (!onlyOverridden) {
return true;
}
return target.edited;
});
},
@computed("currentTargetName", "onlyOverridden", "theme.fields")
visibleFields(targetName, onlyOverridden, fields) {
fields = fields[targetName];
if (onlyOverridden) {
fields = fields.filter(field => field.edited);
}
return fields;
},
@computed("currentTargetName", "fieldName")
activeSectionMode(targetName, fieldName) {
if (["settings", "translations"].includes(targetName)) return "yaml";
return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html";
},
@computed("fieldName", "currentTargetName", "theme")
activeSection: {
get(fieldName, target, model) {
return model.getField(target, fieldName);
},
set(value, fieldName, target, model) {
model.setField(target, fieldName, value);
return value;
}
},
@computed("fieldName", "currentTargetName")
editorId(fieldName, currentTarget) {
return fieldName + "|" + currentTarget;
},
@computed("maximized")
maximizeIcon(maximized) {
return maximized ? "discourse-compress" : "discourse-expand";
},
@computed("currentTargetName", "theme.targets")
showAddField(currentTargetName, targets) {
return targets.find(t => t.name === currentTargetName).customNames;
},
@computed("currentTargetName", "fieldName", "theme.theme_fields.@each.error")
error(target, fieldName) {
return this.get("theme").getError(target, fieldName);
},
actions: {
toggleShowAdvanced() {
this.toggleProperty("showAdvanced");
},
toggleAddField() {
this.toggleProperty("addingField");
},
cancelAddField() {
this.set("addingField", false);
},
addField(name) {
if (!name) return;
name = name.replace(/\W/g, "");
this.get("theme").setField(this.get("currentTargetName"), name, "");
this.set("newFieldName", "");
this.set("addingField", false);
this.fieldAdded(this.get("currentTargetName"), name);
},
toggleMaximize: function() {
this.toggleProperty("maximized");
Ember.run.next(() => {
this.appEvents.trigger("ace:resize");
});
},
onlyOverriddenChanged(value) {
this.onlyOverriddenChanged(value);
}
}
});

View File

@ -1,163 +1,28 @@
import { url } from "discourse/lib/computed";
import {
default as computed,
observes
} from "ember-addons/ember-computed-decorators";
import { default as computed } from "ember-addons/ember-computed-decorators";
export default Ember.Controller.extend({
section: null,
currentTarget: 0,
maximized: false,
previewUrl: url("model.id", "/admin/themes/%@/preview"),
showAdvanced: false,
editRouteName: "adminCustomizeThemes.edit",
targets: [
{ id: 0, name: "common" },
{ id: 1, name: "desktop" },
{ id: 2, name: "mobile" },
{ id: 3, name: "settings" },
{ id: 4, name: "translations" }
],
fieldsForTarget: function(target) {
const common = [
"scss",
"head_tag",
"header",
"after_header",
"body_tag",
"footer"
];
switch (target) {
case "common":
return [...common, "embedded_scss"];
case "desktop":
return common;
case "mobile":
return common;
case "settings":
return ["yaml"];
}
},
@computed("onlyOverridden")
showCommon() {
return this.shouldShow("common");
},
@computed("onlyOverridden")
showDesktop() {
return this.shouldShow("desktop");
},
@computed("onlyOverridden")
showMobile() {
return this.shouldShow("mobile");
},
@observes("onlyOverridden")
onlyOverriddenChanged() {
if (this.get("onlyOverridden")) {
if (
!this.get("model").hasEdited(
this.get("currentTargetName"),
this.get("fieldName")
)
) {
let target =
(this.get("showCommon") && "common") ||
(this.get("showDesktop") && "desktop") ||
(this.get("showMobile") && "mobile");
let fields = this.get("model.theme_fields");
let field = fields && fields.find(f => f.target === target);
this.replaceRoute(
this.get("editRouteName"),
this.get("model.id"),
target,
field && field.name
);
}
}
},
shouldShow(target) {
if (!this.get("onlyOverridden")) {
return true;
}
return this.get("model").hasEdited(target);
},
showRouteName: "adminCustomizeThemes.show",
setTargetName: function(name) {
const target = this.get("targets").find(t => t.name === name);
const target = this.get("model.targets").find(t => t.name === name);
this.set("currentTarget", target && target.id);
},
@computed("currentTarget")
currentTargetName(id) {
const target = this.get("targets").find(t => t.id === parseInt(id, 10));
const target = this.get("model.targets").find(
t => t.id === parseInt(id, 10)
);
return target && target.name;
},
@computed("fieldName")
activeSectionMode(fieldName) {
if (fieldName === "yaml") return "yaml";
return fieldName && fieldName.indexOf("scss") > -1 ? "scss" : "html";
},
@computed("currentTargetName", "fieldName", "saving")
error(target, fieldName) {
return this.get("model").getError(target, fieldName);
},
@computed("fieldName", "currentTargetName")
editorId(fieldName, currentTarget) {
return fieldName + "|" + currentTarget;
},
@computed("fieldName", "currentTargetName", "model")
activeSection: {
get(fieldName, target, model) {
return model.getField(target, fieldName);
},
set(value, fieldName, target, model) {
model.setField(target, fieldName, value);
return value;
}
},
@computed("currentTargetName", "onlyOverridden")
fields(target, onlyOverridden) {
let fields = this.fieldsForTarget(target);
if (onlyOverridden) {
const model = this.get("model");
const targetName = this.get("currentTargetName");
fields = fields.filter(name => model.hasEdited(targetName, name));
}
return fields.map(name => {
let hash = {
key: `admin.customize.theme.${name}.text`,
name: name
};
if (name.indexOf("_tag") > 0) {
hash.icon = "file-text-o";
}
hash.title = I18n.t(`admin.customize.theme.${name}.title`);
return hash;
});
},
@computed("maximized")
maximizeIcon(maximized) {
return maximized ? "discourse-compress" : "discourse-expand";
},
@computed("model.isSaving")
saveButtonText(isSaving) {
return isSaving ? I18n.t("saving") : I18n.t("admin.customize.save");
@ -178,11 +43,36 @@ export default Ember.Controller.extend({
});
},
toggleMaximize: function() {
this.toggleProperty("maximized");
Ember.run.next(() => {
this.appEvents.trigger("ace:resize");
});
fieldAdded(target, name) {
this.replaceRoute(
this.get("editRouteName"),
this.get("model.id"),
target,
name
);
},
onlyOverriddenChanged(onlyShowOverridden) {
if (onlyShowOverridden) {
if (
!this.get("model").hasEdited(
this.get("currentTargetName"),
this.get("fieldName")
)
) {
let firstTarget = this.get("model.targets").find(t => t.edited);
let firstField = this.get(`model.fields.${firstTarget.name}`).find(
f => f.edited
);
this.replaceRoute(
this.get("editRouteName"),
this.get("model.id"),
firstTarget.name,
firstField.name
);
}
}
}
}
});

View File

@ -9,11 +9,87 @@ export const COMPONENTS = "components";
const SETTINGS_TYPE_ID = 5;
const Theme = RestModel.extend({
FIELDS_IDS: [0, 1],
FIELDS_IDS: [0, 1, 5],
isActive: Ember.computed.or("default", "user_selectable"),
isPendingUpdates: Ember.computed.gt("remote_theme.commits_behind", 0),
hasEditedFields: Ember.computed.gt("editedFields.length", 0),
@computed("theme_fields.[]")
targets() {
return [
{ id: 0, name: "common" },
{ id: 1, name: "desktop", icon: "desktop" },
{ id: 2, name: "mobile", icon: "mobile-alt" },
{ id: 3, name: "settings", icon: "cog", advanced: true },
{
id: 4,
name: "translations",
icon: "globe",
advanced: true,
customNames: true
}
].map(target => {
target["edited"] = this.hasEdited(target.name);
target["error"] = this.hasError(target.name);
return target;
});
},
@computed("theme_fields.[]")
fieldNames() {
const common = [
"scss",
"head_tag",
"header",
"after_header",
"body_tag",
"footer"
];
return {
common: [...common, "embedded_scss"],
desktop: common,
mobile: common,
settings: ["yaml"],
translations: [
"en",
...(this.get("theme_fields") || [])
.filter(f => f.target === "translations" && f.name !== "en")
.map(f => f.name)
]
};
},
@computed("fieldNames", "theme_fields.@each.error")
fields(fieldNames) {
const hash = {};
Object.keys(fieldNames).forEach(target => {
hash[target] = fieldNames[target].map(fieldName => {
const field = {
name: fieldName,
edited: this.hasEdited(target, fieldName),
error: this.hasError(target, fieldName)
};
if (target === "translations") {
field.translatedName = fieldName;
} else {
field.translatedName = I18n.t(
`admin.customize.theme.${fieldName}.text`
);
field.title = I18n.t(`admin.customize.theme.${fieldName}.title`);
}
if (fieldName.indexOf("_tag") > 0) {
field.icon = "far-file-alt";
}
return field;
});
});
return hash;
},
@computed("theme_fields")
themeFields(fields) {
if (!fields) {
@ -76,6 +152,14 @@ const Theme = RestModel.extend({
}
},
hasError(target, name) {
return this.get("theme_fields")
.filter(f => {
return f.target === target && (!name || name === f.name);
})
.any(f => f.error);
},
getError(target, name) {
let themeFields = this.get("themeFields");
let key = this.getKey({ target, name });
@ -114,7 +198,7 @@ const Theme = RestModel.extend({
existing.value = value;
existing.upload_id = upload_id;
} else {
fields.push(field);
fields.pushObject(field);
}
return;
}
@ -123,10 +207,15 @@ const Theme = RestModel.extend({
let key = this.getKey({ target, name });
let existingField = themeFields[key];
if (!existingField) {
this.theme_fields.push(field);
this.theme_fields.pushObject(field);
themeFields[key] = field;
} else {
existingField.value = value;
if (Ember.isEmpty(value)) {
this.theme_fields.removeObject(themeFields[key]);
themeFields[key] = null;
} else {
existingField.value = value;
}
}
},

View File

@ -21,7 +21,7 @@ export default Ember.Route.extend({
},
setupController(controller, wrapper) {
const fields = controller.fieldsForTarget(wrapper.target);
const fields = wrapper.model.get("fields")[wrapper.target].map(f => f.name);
if (!fields.includes(wrapper.field_name)) {
this.transitionTo(
"adminCustomizeThemes.edit",

View File

@ -0,0 +1,94 @@
<div class='edit-main-nav admin-controls'>
<nav>
<ul class='nav nav-pills target'>
{{#each visibleTargets as |target|}}
<li>
{{#link-to editRouteName
theme.id
target.name
fieldName
replace=true
title=field.title
class=(if target.edited 'edited' 'blank')
}}
{{#if target.error}}{{d-icon 'exclamation-triangle'}}{{/if}}
{{#if target.icon}}
{{d-icon target.icon}}
{{/if}}
{{i18n (concat 'admin.customize.theme.' target.name)}}
{{/link-to}}
</li>
{{/each}}
<li>
<a {{action "toggleShowAdvanced"}}
class='no-text'
title="{{i18n (concat "admin.customize.theme." (if showAdvanced "hide_advanced" "show_advanced"))}}"
>
{{#if showAdvanced}}
{{d-icon "angle-double-left"}}
{{else}}
{{d-icon "angle-double-right"}}
{{/if}}
</a>
</li>
<li class="spacer"></li>
<li>
<label>
{{input type="checkbox" checked=onlyOverridden click=(action "onlyOverriddenChanged" value="target.checked")}}
{{i18n 'admin.customize.theme.hide_unused_fields'}}
</label>
</li>
</ul>
</nav>
</div>
<div class='admin-controls'>
<nav>
<ul class='nav nav-pills fields'>
{{#each visibleFields as |field|}}
<li>
{{#link-to editRouteName
theme.id
currentTargetName
field.name
replace=true
title=field.title
class=(if field.edited 'edited' 'blank')
}}
{{#if field.error}}{{d-icon 'exclamation-triangle'}}{{/if}}
{{#if field.icon}}{{d-icon field.icon}}{{/if}}
{{field.translatedName}}
{{/link-to}}
</li>
{{/each}}
{{#if showAddField}}
<li>
{{#if addingField}}
{{input type=text value=newFieldName enter=(action 'addField') escape-press=(action "cancelAddField")}}
{{d-button class="ok" action=(action "addField" newFieldName) icon="check"}}
{{d-button class="cancel" action=(action "cancelAddField") icon="times"}}
{{else}}
<a {{action "toggleAddField" currentTargetName}} class="no-text">
{{d-icon "plus"}}
</a>
{{/if}}
</li>
{{/if}}
<li class='spacer'></li>
<li>
<a {{action "toggleMaximize"}} class="no-text">
{{d-icon maximizeIcon}}
</a>
</li>
</ul>
</nav>
</div>
{{#if error}}
<pre class='field-error'>{{error}}</pre>
{{/if}}
{{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true"}}

View File

@ -1,66 +1,17 @@
<div class="current-style {{if maximized 'maximized'}}">
<div class='wrapper'>
<h2>{{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to 'adminCustomizeThemes.show' model.id replace=true}}{{model.name}}{{/link-to}}</h2>
{{#if error}}
<pre class='field-error'>{{error}}</pre>
{{/if}}
<div class='edit-main-nav'>
<ul class='nav nav-pills target'>
{{#if showCommon}}
<li>
{{#link-to 'adminCustomizeThemes.edit' model.id 'common' fieldName replace=true}}
{{i18n 'admin.customize.theme.common'}}
{{/link-to}}
</li>
{{/if}}
{{#if showDesktop}}
<li>
{{#link-to 'adminCustomizeThemes.edit' model.id 'desktop' fieldName replace=true}}
{{i18n 'admin.customize.theme.desktop'}}
{{d-icon 'desktop'}}
{{/link-to}}
</li>
{{/if}}
{{#if showMobile}}
<li class='mobile'>
{{#link-to 'adminCustomizeThemes.edit' model.id 'mobile' fieldName replace=true}}
{{i18n 'admin.customize.theme.mobile'}}
{{d-icon 'mobile'}}
{{/link-to}}
</li>
{{/if}}
</ul>
<div class='show-overidden'>
<label>
{{input type="checkbox" checked=onlyOverridden}}
{{i18n 'admin.settings.show_overriden'}}
</label>
</div>
<div class='clearfix'></div>
</div>
<div class='admin-controls'>
<ul class='nav nav-pills fields'>
{{#each fields as |field|}}
<li>
{{#link-to 'adminCustomizeThemes.edit' model.id currentTargetName field.name replace=true title=field.title}}
{{#if field.icon}}{{d-icon field.icon}} {{/if}}
{{i18n field.key}}
{{/link-to}}
</li>
{{/each}}
<li class='toggle-maximize'>
<a {{action "toggleMaximize"}}>
{{d-icon maximizeIcon}}
</a>
</li>
</ul>
</div>
{{ace-editor content=activeSection editorId=editorId mode=activeSectionMode autofocus="true"}}
<h2>{{i18n 'admin.customize.theme.edit_css_html'}} {{#link-to showRouteName model.id replace=true}}{{model.name}}{{/link-to}}</h2>
{{admin-theme-editor
theme=model
editRouteName=editRouteName
currentTargetName=currentTargetName
fieldName=fieldName
fieldAdded=(action 'fieldAdded')
maximized=maximized
onlyOverriddenChanged=(action 'onlyOverriddenChanged')
}}
<div class='admin-footer'>
<div class='status-actions'>
{{#unless model.changed}}

View File

@ -91,18 +91,6 @@
padding: 5px;
}
.edit-main-nav {
.nav-pills:after,
.nav-pills:before {
display: inline;
content: "";
}
.show-overidden {
float: right;
}
margin-bottom: 10px;
}
.admin-container {
padding-left: 10px;
padding-right: 10px;
@ -390,9 +378,6 @@
}
}
.nav-pills.fields {
margin-left: 10px;
}
.content-list,
.current-style {
float: left;
@ -407,34 +392,50 @@
margin: 0;
}
.nav.target {
margin-top: 15px;
li {
position: relative;
a {
display: flex;
align-items: center;
justify-content: space-between;
}
}
.fa {
margin-left: 8px;
}
.d-icon-mobile {
position: relative;
top: -3px;
font-size: $font-up-3;
max-height: 20px;
}
.edit-main-nav .nav-pills > li a.active {
background-color: $quaternary;
color: $secondary;
}
.toggle-maximize {
position: absolute;
right: -5px;
.edit-main-nav ul {
padding-bottom: 0;
}
.nav-pills {
li {
margin-right: 0;
display: flex;
&.spacer {
flex-grow: 1;
}
&:last-of-type > a {
margin-right: 0;
}
a.no-text .d-icon {
margin-right: 0;
}
label {
padding: 6px 12px;
margin-bottom: 0;
}
a.blank:not(.active) {
color: $primary-medium;
}
input {
margin-bottom: 0;
margin-left: 6px;
}
button {
margin-right: 0;
}
}
}
.ace-wrapper {

View File

@ -3344,7 +3344,11 @@ en:
desktop: "Desktop"
mobile: "Mobile"
settings: "Settings"
translations: "Translations"
preview: "Preview"
show_advanced: "Show advanced fields"
hide_advanced: "Hide advanced fields"
hide_unused_fields: "Hide unused fields"
is_default: "Theme is enabled by default"
user_selectable: "Theme can be selected by users"
color_scheme: "Color Palette"

View File

@ -9,6 +9,8 @@ module SvgSprite
"anchor",
"angle-double-down",
"angle-double-up",
"angle-double-right",
"angle-double-left",
"angle-down",
"angle-right",
"angle-up",