From 715ee0810043391d3291e8bdcbf7f974fc62e425 Mon Sep 17 00:00:00 2001 From: Kevin Ansfield Date: Tue, 5 Oct 2021 20:44:27 +0100 Subject: [PATCH] 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 `` 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 --- .../app/components/gh-theme-table-labs.hbs | 32 ++++++ .../app/components/gh-theme-table-labs.js | 99 +++++++++++++++++++ ghost/admin/app/components/modals/design.hbs | 4 + .../app/components/modals/design/advanced.hbs | 13 ++- .../app/components/modals/design/advanced.js | 11 +++ .../modals/design/confirm-delete-theme.hbs | 20 ++++ .../modals/design/confirm-delete-theme.js | 28 ++++++ .../components/modals/design/theme-errors.hbs | 67 +++++++++++++ .../components/modals/limits/custom-theme.hbs | 27 +++++ ghost/admin/app/router.js | 1 + ghost/admin/app/routes/settings/design.js | 24 ++--- .../app/routes/settings/design/advanced.js | 35 +++++++ ghost/admin/app/services/modals.js | 6 ++ ghost/admin/app/services/theme-management.js | 86 ++++++++++++++++ .../admin/app/styles/components/dropdowns.css | 7 ++ 15 files changed, 443 insertions(+), 17 deletions(-) create mode 100644 ghost/admin/app/components/gh-theme-table-labs.hbs create mode 100644 ghost/admin/app/components/gh-theme-table-labs.js create mode 100644 ghost/admin/app/components/modals/design/confirm-delete-theme.hbs create mode 100644 ghost/admin/app/components/modals/design/confirm-delete-theme.js create mode 100644 ghost/admin/app/components/modals/design/theme-errors.hbs create mode 100644 ghost/admin/app/components/modals/limits/custom-theme.hbs create mode 100644 ghost/admin/app/routes/settings/design/advanced.js create mode 100644 ghost/admin/app/services/theme-management.js diff --git a/ghost/admin/app/components/gh-theme-table-labs.hbs b/ghost/admin/app/components/gh-theme-table-labs.hbs new file mode 100644 index 0000000000..0de86b1f99 --- /dev/null +++ b/ghost/admin/app/components/gh-theme-table-labs.hbs @@ -0,0 +1,32 @@ +
+ {{#each this.sortedThemes as |theme index|}} +
+
+
+

+ {{theme.label}} + {{#if theme.active}}Active{{/if}} +

+

Version {{theme.version}}

+
+ + {{svg-jar "dotdotdot"}} + + + + + +
+
+ {{/each}} +
diff --git a/ghost/admin/app/components/gh-theme-table-labs.js b/ghost/admin/app/components/gh-theme-table-labs.js new file mode 100644 index 0000000000..89f7094fad --- /dev/null +++ b/ghost/admin/app/components/gh-theme-table-labs.js @@ -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; + }); + } +} diff --git a/ghost/admin/app/components/modals/design.hbs b/ghost/admin/app/components/modals/design.hbs index ff3e3f03ed..810b62ed65 100644 --- a/ghost/admin/app/components/modals/design.hbs +++ b/ghost/admin/app/components/modals/design.hbs @@ -76,6 +76,10 @@ {{/if}} {{/if}} + +
+ {{svg-jar "settings"}} Advanced theme settings +
diff --git a/ghost/admin/app/components/modals/design/advanced.hbs b/ghost/admin/app/components/modals/design/advanced.hbs index bbb7422e88..0baa2679a4 100644 --- a/ghost/admin/app/components/modals/design/advanced.hbs +++ b/ghost/admin/app/components/modals/design/advanced.hbs @@ -7,12 +7,15 @@ \ No newline at end of file diff --git a/ghost/admin/app/components/modals/design/advanced.js b/ghost/admin/app/components/modals/design/advanced.js index 0432cd7b44..13cb686fcc 100644 --- a/ghost/admin/app/components/modals/design/advanced.js +++ b/ghost/admin/app/components/modals/design/advanced.js @@ -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'); + } } diff --git a/ghost/admin/app/components/modals/design/confirm-delete-theme.hbs b/ghost/admin/app/components/modals/design/confirm-delete-theme.hbs new file mode 100644 index 0000000000..b45e4919f8 --- /dev/null +++ b/ghost/admin/app/components/modals/design/confirm-delete-theme.hbs @@ -0,0 +1,20 @@ + \ No newline at end of file diff --git a/ghost/admin/app/components/modals/design/confirm-delete-theme.js b/ghost/admin/app/components/modals/design/confirm-delete-theme.js new file mode 100644 index 0000000000..605ddb6414 --- /dev/null +++ b/ghost/admin/app/components/modals/design/confirm-delete-theme.js @@ -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); + } + } +} diff --git a/ghost/admin/app/components/modals/design/theme-errors.hbs b/ghost/admin/app/components/modals/design/theme-errors.hbs new file mode 100644 index 0000000000..8e64f5b64f --- /dev/null +++ b/ghost/admin/app/components/modals/design/theme-errors.hbs @@ -0,0 +1,67 @@ + \ No newline at end of file diff --git a/ghost/admin/app/components/modals/limits/custom-theme.hbs b/ghost/admin/app/components/modals/limits/custom-theme.hbs new file mode 100644 index 0000000000..d96d8e3922 --- /dev/null +++ b/ghost/admin/app/components/modals/limits/custom-theme.hbs @@ -0,0 +1,27 @@ + + \ No newline at end of file diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index b90401a7a0..178d2fd59c 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -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'); }); diff --git a/ghost/admin/app/routes/settings/design.js b/ghost/admin/app/routes/settings/design.js index 13ef95aac7..fd6a8129fb 100644 --- a/ghost/admin/app/routes/settings/design.js +++ b/ghost/admin/app/routes/settings/design.js @@ -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() { diff --git a/ghost/admin/app/routes/settings/design/advanced.js b/ghost/admin/app/routes/settings/design/advanced.js new file mode 100644 index 0000000000..70814b74a1 --- /dev/null +++ b/ghost/admin/app/routes/settings/design/advanced.js @@ -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'); + } +} diff --git a/ghost/admin/app/services/modals.js b/ghost/admin/app/services/modals.js index 98bbd85781..43ce2d1e61 100644 --- a/ghost/admin/app/services/modals.js +++ b/ghost/admin/app/services/modals.js @@ -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' } }; diff --git a/ghost/admin/app/services/theme-management.js b/ghost/admin/app/services/theme-management.js new file mode 100644 index 0000000000..c6fa921049 --- /dev/null +++ b/ghost/admin/app/services/theme-management.js @@ -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(); + } + } +} diff --git a/ghost/admin/app/styles/components/dropdowns.css b/ghost/admin/app/styles/components/dropdowns.css index 1e68dc43f2..608a68b9ac 100644 --- a/ghost/admin/app/styles/components/dropdowns.css +++ b/ghost/admin/app/styles/components/dropdowns.css @@ -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;