mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-24 23:48:13 -05:00
Added advanced theme settings modal
refs https://github.com/TryGhost/Team/issues/1111 Extracted functionality for listing, downloading, activating, and deleting from the theme controller/template into separate components and services so that they are more composable/reusable in different situations. - moved theme activation to a new `theme-management` service that uses the `modals` service to open the theme warnings modal or limits upgrade modal as required - the activate process is a task so that consumers can store a reference to the task instance and cancel it to close any related warning/limit modals (eg, when navigating away from the route or closing the modal that kicked off the process) - created new-pattern modals for custom theme limit upgrade, theme errors, and delete confirmation so that we can treat them as promises and close where needed from parent - duplicated theme table component as `<GhThemeTableLabs>` with an actions redesign and a refactor to handle download, activation, and deletion itself making use of the new theme-management service and modals - fixed some oddities with design modal's transition/modal close handling by simplifying the async behaviour and being more explicit - added advanced design modal that contains the new theme table component and linked to it from footer of design modal's sidebar
This commit is contained in:
parent
6155645a45
commit
715ee08100
15 changed files with 443 additions and 17 deletions
32
ghost/admin/app/components/gh-theme-table-labs.hbs
Normal file
32
ghost/admin/app/components/gh-theme-table-labs.hbs
Normal file
|
@ -0,0 +1,32 @@
|
|||
<div class="apps-grid" data-test-themes-list>
|
||||
{{#each this.sortedThemes as |theme index|}}
|
||||
<div class="apps-grid-cell" data-test-theme-id="{{theme.name}}" data-test-theme-active="{{theme.active}}">
|
||||
<div class="apps-card-app {{if theme.active "theme-list-item--active"}}">
|
||||
<div class="apps-card-meta flex-grow-1">
|
||||
<h3 class="apps-card-app-title" data-test-theme-title>
|
||||
{{theme.label}}
|
||||
{{#if theme.active}}<span class="gh-badge gh-badge-green">Active</span>{{/if}}
|
||||
</h3>
|
||||
<p class="apps-card-app-desc" data-test-theme-description><span class="description">Version {{theme.version}}</span></p>
|
||||
</div>
|
||||
<GhBasicDropdown @verticalPosition="below" @horizontalPosition="right" @buttonPosition="right" as |dd|>
|
||||
<dd.Trigger class="gh-btn gh-btn-icon gh-btn-white"><span>{{svg-jar "dotdotdot"}}</span></dd.Trigger>
|
||||
|
||||
<dd.Content>
|
||||
<ul class="dropdown-menu">
|
||||
{{#unless theme.active}}
|
||||
<li><button type="button" {{on "click" (fn this.activateTheme theme.model dd)}} class="darkgrey apps-configured-action-activate green-hover green-bg-hover" data-test-button="activate">Activate</button></li>
|
||||
{{/unless}}
|
||||
|
||||
<li><button type="button" {{on "click" (fn this.downloadTheme theme.name dd)}} class="darkgrey darkgrey-hover lightgrey-bg-hover" data-test-button="download">Download</button></li>
|
||||
|
||||
{{#if theme.isDeletable}}
|
||||
<li><button type="button" {{on "click" (fn this.deleteTheme theme.model dd)}} disabled={{theme.active}} class="red red-hover red-bg-hover" data-test-button="delete">Delete</button></li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</dd.Content>
|
||||
</GhBasicDropdown>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
99
ghost/admin/app/components/gh-theme-table-labs.js
Normal file
99
ghost/admin/app/components/gh-theme-table-labs.js
Normal file
|
@ -0,0 +1,99 @@
|
|||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {get} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class GhThemeTableComponent extends Component {
|
||||
@service ghostPaths;
|
||||
@service modals;
|
||||
@service themeManagement;
|
||||
@service utils;
|
||||
|
||||
activateTaskInstance = null;
|
||||
confirmDeleteModal = null;
|
||||
|
||||
willDestroy() {
|
||||
super.willDestroy(...arguments);
|
||||
this.confirmDeleteModal?.close();
|
||||
this.activateTaskInstance?.cancel();
|
||||
}
|
||||
|
||||
get sortedThemes() {
|
||||
let themes = this.args.themes.map((t) => {
|
||||
let theme = {};
|
||||
let themePackage = get(t, 'package');
|
||||
|
||||
theme.model = t;
|
||||
theme.name = get(t, 'name');
|
||||
theme.label = themePackage ? `${themePackage.name}` : theme.name;
|
||||
theme.version = themePackage ? `${themePackage.version}` : '1.0';
|
||||
theme.package = themePackage;
|
||||
theme.active = get(t, 'active');
|
||||
theme.isDeletable = !theme.active;
|
||||
|
||||
return theme;
|
||||
});
|
||||
let duplicateThemes = [];
|
||||
|
||||
themes.forEach((theme) => {
|
||||
let duplicateLabels = themes.filterBy('label', theme.label);
|
||||
|
||||
if (duplicateLabels.length > 1) {
|
||||
duplicateThemes.pushObject(theme);
|
||||
}
|
||||
});
|
||||
|
||||
duplicateThemes.forEach((theme) => {
|
||||
if (theme.name !== 'casper') {
|
||||
theme.label = `${theme.label} (${theme.name})`;
|
||||
}
|
||||
});
|
||||
|
||||
// "(default)" needs to be added to casper manually as it's always
|
||||
// displayed and would mess up the duplicate checking if added earlier
|
||||
let casper = themes.findBy('name', 'casper');
|
||||
if (casper) {
|
||||
casper.label = `${casper.label} (default)`;
|
||||
casper.isDefault = true;
|
||||
casper.isDeletable = false;
|
||||
}
|
||||
|
||||
// sorting manually because .sortBy('label') has a different sorting
|
||||
// algorithm to [...strings].sort()
|
||||
return themes.sort((themeA, themeB) => {
|
||||
let a = themeA.label.toLowerCase();
|
||||
let b = themeB.label.toLowerCase();
|
||||
|
||||
if (a < b) {
|
||||
return -1;
|
||||
}
|
||||
if (a > b) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
downloadTheme(themeName, dropdown) {
|
||||
dropdown?.actions.close();
|
||||
this.utils.downloadFile(`${this.ghostPaths.apiRoot}/themes/${themeName}/download/`);
|
||||
}
|
||||
|
||||
@action
|
||||
activateTheme(theme, dropdown) {
|
||||
dropdown?.actions.close();
|
||||
this.activateTaskInstance = this.themeManagement.activateTask.perform(theme);
|
||||
}
|
||||
|
||||
@action
|
||||
deleteTheme(theme, dropdown) {
|
||||
dropdown?.actions.close();
|
||||
|
||||
this.confirmDeleteModal = this.modals.open('modals/design/confirm-delete-theme', {
|
||||
theme
|
||||
}).finally(() => {
|
||||
this.confirmDeleteModal = null;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -76,6 +76,10 @@
|
|||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div class="gh-nav-bottom">
|
||||
<LinkTo @route="settings.design.advanced" class="gh-btn gh-btn-icon gh-btn-text"><span>{{svg-jar "settings"}} Advanced theme settings</span></LinkTo>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
|
|
|
@ -7,12 +7,15 @@
|
|||
<div class="modal-body">
|
||||
<div class="gh-setting-header">Installed Themes</div>
|
||||
<div class="gh-themes-container">
|
||||
<GhThemeTableLabs @themes={{this.themes}} />
|
||||
|
||||
<GhThemeTable
|
||||
@themes={{this.themes}}
|
||||
@activateTheme={{optional this.noop}}
|
||||
@downloadTheme={{optional this.noop}}
|
||||
@deleteTheme={{optional this.noop}} />
|
||||
<div class="flex justify-between mt6">
|
||||
<a href="https://ghost.org/docs/themes/" target=_"blank" rel="noopener noreferrer" class="gh-btn gh-btn-outline">
|
||||
<span>Theme developer docs</span>
|
||||
</a>
|
||||
|
||||
<button type="button" class="gh-btn gh-btn-black" {{on "click" @close}}><span>Done</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,5 +1,6 @@
|
|||
import Component from '@glimmer/component';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency-decorators';
|
||||
|
||||
export default class ModalsDesignAdvancedComponent extends Component {
|
||||
@service store;
|
||||
|
@ -7,4 +8,14 @@ export default class ModalsDesignAdvancedComponent extends Component {
|
|||
get themes() {
|
||||
return this.store.peekAll('theme');
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.loadThemesTask.perform();
|
||||
}
|
||||
|
||||
@task
|
||||
*loadThemesTask() {
|
||||
yield this.store.findAll('theme');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
<div class="modal-content">
|
||||
<header class="modal-header" data-test-modal="delete-theme">
|
||||
<h1>Are you sure you want to delete this</h1>
|
||||
</header>
|
||||
<button type="button" class="close" role="button" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>You're about to delete "<strong>{{@data.theme.name}}</strong>". This is permanent! We warned you, k? Maybe <a href="#" {{on "click" this.downloadTheme}}>download your theme before continuing</a></p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button {{on "click" (fn @close false)}} class="gh-btn" data-test-button="cancel"><span>Stay</span></button>
|
||||
<GhTaskButton
|
||||
@buttonText="Delete"
|
||||
@successText="Deleted"
|
||||
@task={{this.deleteThemeTask}}
|
||||
@class="gh-btn gh-btn-red gh-btn-icon"
|
||||
data-test-button="delete" />
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,28 @@
|
|||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency-decorators';
|
||||
|
||||
export default class ConfirmDeleteThemeComponent extends Component {
|
||||
@service ghostPaths;
|
||||
@service notifications;
|
||||
@service utils;
|
||||
|
||||
@action
|
||||
downloadTheme(event) {
|
||||
event.preventDefault();
|
||||
this.utils.downloadFile(`${this.ghostPaths.apiRoot}/themes/${this.args.data.theme.name}/download/`);
|
||||
}
|
||||
|
||||
@task
|
||||
*deleteThemeTask() {
|
||||
try {
|
||||
yield this.args.data.theme.destroyRecord();
|
||||
this.args.close();
|
||||
return true;
|
||||
} catch (error) {
|
||||
// TODO: show error in modal rather than generic message
|
||||
this.notifications.showAPIError(error);
|
||||
}
|
||||
}
|
||||
}
|
67
ghost/admin/app/components/modals/design/theme-errors.hbs
Normal file
67
ghost/admin/app/components/modals/design/theme-errors.hbs
Normal file
|
@ -0,0 +1,67 @@
|
|||
<div class="modal-content">
|
||||
<div class="theme-validation-container" data-test-modal="theme-errors">
|
||||
<header class="modal-header">
|
||||
<h1 data-test-theme-warnings-title>
|
||||
{{#unless @data.canActivate}}
|
||||
{{@data.title}}
|
||||
{{else}}
|
||||
{{@data.title}} with {{#if @data.errors}}errors{{else}}warnings{{/if}}
|
||||
{{/unless}}
|
||||
</h1>
|
||||
</header>
|
||||
<button type="button" class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
|
||||
|
||||
<div class="modal-body">
|
||||
{{#if @data.fatalErrors}}
|
||||
<div>
|
||||
<h2 class="mb0 mt4 f5 fw6 red">Fatal Errors</h2>
|
||||
<p class="mb2 red">Must-fix to activate theme</p>
|
||||
</div>
|
||||
<ul class="pa0" data-test-theme-fatal-errors>
|
||||
{{#each @data.fatalErrors as |error|}}
|
||||
<li class="theme-validation-item theme-fatal-error">
|
||||
<GhThemeErrorLi @error={{error}} />
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{#if @data.errors}}
|
||||
<div>
|
||||
<h2 class="mb0 mt4 f5 fw6">Errors</h2>
|
||||
<p class="mb2">Highly recommended to fix, functionality <span>could</span> be restricted</p>
|
||||
</div>
|
||||
|
||||
<ul class="pa0" data-test-theme-errors>
|
||||
{{#each @data.errors as |error|}}
|
||||
<li class="theme-validation-item theme-error">
|
||||
<GhThemeErrorLi @error={{error}} />
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
|
||||
{{#if (and @data.warnings (or @data.fatalErrors @data.errors))}}
|
||||
<div>
|
||||
<h2 class="mb0 mt4 f5 fw6">Warnings</h2>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if @data.warnings}}
|
||||
<ul class="pa0" data-test-theme-warnings>
|
||||
{{#each @data.warnings as |error|}}
|
||||
<li class="theme-validation-item theme-warning">
|
||||
<GhThemeErrorLi @error={{error}} />
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" {{on "click" @close}} class="gh-btn" data-test-modal-close-button>
|
||||
<span>Ok</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
27
ghost/admin/app/components/modals/limits/custom-theme.hbs
Normal file
27
ghost/admin/app/components/modals/limits/custom-theme.hbs
Normal file
|
@ -0,0 +1,27 @@
|
|||
|
||||
<div class="modal-content">
|
||||
<header class="modal-header" data-test-modal="delete-user">
|
||||
<h1>Upgrade to enable custom themes</h1>
|
||||
</header>
|
||||
<button class="close" title="Close" {{on "click" @close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
{{#if @data.message}}
|
||||
{{html-safe @data.message}}
|
||||
{{else}}
|
||||
Your current plan only supports official themes. You can install them from the <a href="https://ghost.org/marketplace/">Ghost theme marketplace</a>.
|
||||
{{/if}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button {{on "click" @close}} class="gh-btn" data-test-button="cancel-upgrade">
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
|
||||
<LinkTo @route="pro" class="gh-btn gh-btn-green" data-test-button="upgrade-plan">
|
||||
<span>Upgrade</span>
|
||||
</LinkTo>
|
||||
</div>
|
||||
</div>
|
|
@ -49,6 +49,7 @@ Router.map(function () {
|
|||
this.route('settings.code-injection', {path: '/settings/code-injection'});
|
||||
|
||||
this.route('settings.design', {path: '/settings/design'}, function () {
|
||||
this.route('advanced');
|
||||
this.route('change-theme');
|
||||
});
|
||||
|
||||
|
|
|
@ -40,20 +40,20 @@ export default class SettingsDesignRoute extends AuthenticatedRoute {
|
|||
}
|
||||
|
||||
@action
|
||||
async willTransition(transition) {
|
||||
if (this.settings.get('hasDirtyAttributes') || this.customThemeSettings.isDirty) {
|
||||
transition.abort();
|
||||
|
||||
const shouldLeave = await this.confirmUnsavedChanges();
|
||||
this.hasConfirmed = true;
|
||||
|
||||
if (shouldLeave) {
|
||||
return transition.retry();
|
||||
}
|
||||
} else {
|
||||
this.hasConfirmed = true;
|
||||
willTransition(transition) {
|
||||
if (this.hasConfirmed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// always abort when not confirmed because Ember's router doesn't automatically wait on promises
|
||||
transition.abort();
|
||||
|
||||
this.confirmUnsavedChanges().then((shouldLeave) => {
|
||||
if (shouldLeave) {
|
||||
this.hasConfirmed = true;
|
||||
return transition.retry();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
|
|
35
ghost/admin/app/routes/settings/design/advanced.js
Normal file
35
ghost/admin/app/routes/settings/design/advanced.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
|
||||
import {action} from '@ember/object';
|
||||
import {bind} from '@ember/runloop';
|
||||
import {inject as service} from '@ember/service';
|
||||
|
||||
export default class SettingsDesignAdvancedRoute extends AuthenticatedRoute {
|
||||
@service modals;
|
||||
|
||||
activate() {
|
||||
this.advancedModal = this.modals.open('modals/design/advanced', {}, {
|
||||
className: 'fullscreen-modal-action fullscreen-modal-wide',
|
||||
beforeClose: bind(this, this.beforeModalClose)
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
willTransition() {
|
||||
this.isTransitioning = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.advancedModal?.close();
|
||||
this.advancedModal = null;
|
||||
this.isTransitioning = false;
|
||||
}
|
||||
|
||||
beforeModalClose() {
|
||||
if (this.isTransitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.transitionTo('settings.design');
|
||||
}
|
||||
}
|
|
@ -5,6 +5,12 @@ import {inject as service} from '@ember/service';
|
|||
export const DEFAULT_MODAL_OPTIONS = {
|
||||
'modals/confirm-unsaved-changes': {
|
||||
className: 'fullscreen-modal-action fullscreen-modal-wide'
|
||||
},
|
||||
'modals/design/confirm-delete-theme': {
|
||||
className: 'fullscreen-modal-action fullscreen-modal-wide'
|
||||
},
|
||||
'modals/limits/custom-theme': {
|
||||
className: 'fullscreen-modal-action fullscreen-modal-wide'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
86
ghost/admin/app/services/theme-management.js
Normal file
86
ghost/admin/app/services/theme-management.js
Normal file
|
@ -0,0 +1,86 @@
|
|||
import Service from '@ember/service';
|
||||
import {isEmpty} from '@ember/utils';
|
||||
import {isThemeValidationError} from 'ghost-admin/services/ajax';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency-decorators';
|
||||
|
||||
export default class ThemeManagementService extends Service {
|
||||
@service limit;
|
||||
@service modals;
|
||||
|
||||
@task
|
||||
*activateTask(theme) {
|
||||
let resultModal = null;
|
||||
|
||||
try {
|
||||
const isOverLimit = yield this.limit.checkWouldGoOverLimit('customThemes', {value: theme.name});
|
||||
|
||||
if (isOverLimit) {
|
||||
try {
|
||||
yield this.limit.limiter.errorIfWouldGoOverLimit('customThemes', {value: theme.name});
|
||||
} catch (error) {
|
||||
if (error.errorType !== 'HostLimitError') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
resultModal = this.modals.open('modals/limits/custom-theme', {
|
||||
message: error.message
|
||||
});
|
||||
|
||||
yield resultModal;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const activatedTheme = yield theme.activate();
|
||||
|
||||
const {warnings, errors} = activatedTheme;
|
||||
|
||||
if (!isEmpty(warnings) || !isEmpty(errors)) {
|
||||
resultModal = this.modals.open('modals/design/theme-errors', {
|
||||
title: 'Activation successful',
|
||||
canActivate: true,
|
||||
warnings,
|
||||
errors
|
||||
});
|
||||
|
||||
yield resultModal;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isThemeValidationError(error)) {
|
||||
let errors = error.payload.errors[0].details.errors;
|
||||
let fatalErrors = [];
|
||||
let normalErrors = [];
|
||||
|
||||
// to have a proper grouping of fatal errors and none fatal, we need to check
|
||||
// our errors for the fatal property
|
||||
if (errors.length > 0) {
|
||||
for (let i = 0; i < errors.length; i += 1) {
|
||||
if (errors[i].fatal) {
|
||||
fatalErrors.push(errors[i]);
|
||||
} else {
|
||||
normalErrors.push(errors[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resultModal = this.modals.open('modals/design/theme-errors', {
|
||||
title: 'Activation failed',
|
||||
canActivate: false,
|
||||
errors: normalErrors,
|
||||
fatalErrors
|
||||
});
|
||||
|
||||
yield resultModal;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// finally is always called even if the task is cancelled which gives
|
||||
// consumers the ability to cancel the task to clear any opened modals
|
||||
resultModal?.close();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -33,6 +33,13 @@
|
|||
font-weight: normal;
|
||||
}
|
||||
|
||||
.ember-basic-dropdown-content .dropdown-menu {
|
||||
position: relative;
|
||||
float: none;
|
||||
top: auto;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.dropdown-menu.pull-right {
|
||||
right: 0;
|
||||
left: auto;
|
||||
|
|
Loading…
Add table
Reference in a new issue