0
Fork 0
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:
Kevin Ansfield 2021-10-05 20:44:27 +01:00
parent 6155645a45
commit 715ee08100
15 changed files with 443 additions and 17 deletions

View 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>

View 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;
});
}
}

View file

@ -76,6 +76,10 @@
{{/if}} {{/if}}
{{/if}} {{/if}}
</div> </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> </section>
</nav> </nav>

View file

@ -7,12 +7,15 @@
<div class="modal-body"> <div class="modal-body">
<div class="gh-setting-header">Installed Themes</div> <div class="gh-setting-header">Installed Themes</div>
<div class="gh-themes-container"> <div class="gh-themes-container">
<GhThemeTableLabs @themes={{this.themes}} />
<GhThemeTable <div class="flex justify-between mt6">
@themes={{this.themes}} <a href="https://ghost.org/docs/themes/" target=_"blank" rel="noopener noreferrer" class="gh-btn gh-btn-outline">
@activateTheme={{optional this.noop}} <span>Theme developer docs</span>
@downloadTheme={{optional this.noop}} </a>
@deleteTheme={{optional this.noop}} />
<button type="button" class="gh-btn gh-btn-black" {{on "click" @close}}><span>Done</span></button>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,6 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
export default class ModalsDesignAdvancedComponent extends Component { export default class ModalsDesignAdvancedComponent extends Component {
@service store; @service store;
@ -7,4 +8,14 @@ export default class ModalsDesignAdvancedComponent extends Component {
get themes() { get themes() {
return this.store.peekAll('theme'); return this.store.peekAll('theme');
} }
constructor() {
super(...arguments);
this.loadThemesTask.perform();
}
@task
*loadThemesTask() {
yield this.store.findAll('theme');
}
} }

View file

@ -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>

View file

@ -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);
}
}
}

View 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>

View 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>

View file

@ -49,6 +49,7 @@ Router.map(function () {
this.route('settings.code-injection', {path: '/settings/code-injection'}); this.route('settings.code-injection', {path: '/settings/code-injection'});
this.route('settings.design', {path: '/settings/design'}, function () { this.route('settings.design', {path: '/settings/design'}, function () {
this.route('advanced');
this.route('change-theme'); this.route('change-theme');
}); });

View file

@ -40,20 +40,20 @@ export default class SettingsDesignRoute extends AuthenticatedRoute {
} }
@action @action
async willTransition(transition) { willTransition(transition) {
if (this.settings.get('hasDirtyAttributes') || this.customThemeSettings.isDirty) { if (this.hasConfirmed) {
transition.abort();
const shouldLeave = await this.confirmUnsavedChanges();
this.hasConfirmed = true;
if (shouldLeave) {
return transition.retry();
}
} else {
this.hasConfirmed = true;
return true; 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() { deactivate() {

View 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');
}
}

View file

@ -5,6 +5,12 @@ import {inject as service} from '@ember/service';
export const DEFAULT_MODAL_OPTIONS = { export const DEFAULT_MODAL_OPTIONS = {
'modals/confirm-unsaved-changes': { 'modals/confirm-unsaved-changes': {
className: 'fullscreen-modal-action fullscreen-modal-wide' 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'
} }
}; };

View 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();
}
}
}

View file

@ -33,6 +33,13 @@
font-weight: normal; font-weight: normal;
} }
.ember-basic-dropdown-content .dropdown-menu {
position: relative;
float: none;
top: auto;
left: auto;
}
.dropdown-menu.pull-right { .dropdown-menu.pull-right {
right: 0; right: 0;
left: auto; left: auto;