Refactored theme upload process and linked from design screens

refs https://github.com/TryGhost/Team/issues/1130

- copied theme upload modal to new modals system and refactored for Octane syntax
  -  updated to use `themeManagement` service rather than passed in actions so the modal-based process can be opened from any screen
  - added default `beforeClose()` for the modal to the modals service so it won't close if an upload is in-progress (defaults were moved directly into the class so it had access to services)
- added `themeManagement.upload` action for triggering the upload modal and providing a central place for limit checks
- added upload-triggering buttons to change-theme and advanced design screens
This commit is contained in:
Kevin Ansfield 2021-10-11 19:30:58 +01:00
parent 3345920f29
commit 13f9cb85fa
8 changed files with 366 additions and 17 deletions

View File

@ -0,0 +1,140 @@
<div class="modal-content">
<div class="theme-validation-container">
<header class="modal-header" data-test-modal="upload-theme">
<h1>
{{#if this.theme}}
{{#if this.hasWarningsOrErrors}}
Upload successful with {{#if this.validationErrors}}errors{{else}}warnings{{/if}}
{{else}}
Upload successful!
{{/if}}
{{else if (or this.validationErrors this.fatalValidationErrors)}}
Invalid theme
{{else}}
Upload a theme
{{/if}}
</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 this.theme}}
{{#if this.hasWarningsOrErrors}}
<p>
The theme <strong>"{{this.themeName}}"</strong> was installed successfully but we detected some {{if this.validationErrors "errors" "warnings"}}.
{{#if this.canActivateTheme}}
You are still able to activate and use the theme but it is recommended to fix these {{if this.validationErrors "errors" "warnings"}} before you do so.
{{/if}}
</p>
{{#if this.validationErrors}}
<div>
<h2 class="mb0 mt4 f5 fw6">Errors</h2>
<p class="mb2">Highly recommended to fix, functionality <strong>could</strong> be restricted</p>
</div>
<ul class="pa0">
{{#each this.validationErrors as |error|}}
<li class="theme-validation-item theme-error">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
{{#if this.validationWarnings}}
<div>
<h2 class="mb0 mt4 f5 fw6">Warnings</h2>
</div>
<ul class="pa0">
{{#each this.validationWarnings as |error|}}
<li class="theme-validation-item theme-warning">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
{{else}}
<p>
"{{this.themeName}}" uploaded successfully.
{{#if this.canActivateTheme}}Do you want to activate it now?{{/if}}
</p>
{{/if}}
{{else if this.displayOverwriteWarning}}
<p>
The theme folder <strong>"{{this.fileThemeName}}"</strong> already exists. Do you want to overwrite it?
</p>
{{else if (or this.validationErrors this.fatalValidationErrors)}}
<p>
This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme.
</p>
{{#if this.fatalValidationErrors}}
<div>
<h2 class="mb0 mt4 f5 fw6">Fatal Errors</h2>
<p class="mb2">Must-fix to activate theme</p>
</div>
<ul class="pa0">
{{#each this.fatalValidationErrors as |error|}}
<li class="theme-validation-item theme-fatal-error">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
{{#if this.validationErrors}}
<div>
<h2 class="mb0 mt4 f5 fw6">Errors</h2>
<p class="mb2">Highly recommended to fix, functionality <strong>could</strong> be restricted</p>
</div>
<ul class="pa0">
{{#each this.validationErrors as |error|}}
<li class="theme-validation-item theme-error">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
{{else}}
<GhFileUploader
@url={{this.uploadUrl}}
@paramName="file"
@accept={{this.accept}}
@labelText="Click to select or drag-and-drop your theme zip file here."
@validate={{this.validateTheme}}
@uploadStarted={{fn (mut this.themeManagement.isUploading) true}}
@uploadFinished={{fn (mut this.themeManagement.isUploading) false}}
@uploadSuccess={{this.uploadSuccess}}
@uploadFailed={{this.uploadFailed}}
@listenTo="themeUploader" />
{{/if}}
</div>
</div>
<div class="modal-footer {{if (and this.theme this.hasWarningsOrErrors) "top-shadow"}}">
<div class="flex items-center justify-between {{if (or this.displayOverwriteWarning this.canActivateTheme this.validationErrors this.fatalValidationErrors) "flex-auto"}}">
<button type="button" {{on "click" @close}} disabled={{this.closeDisabled}} class="gh-btn" data-test-close-button>
<span>{{#if this.theme}}Close{{else}}Cancel{{/if}}</span>
</button>
<div class="flex items-center">
{{#if this.displayOverwriteWarning}}
<button type="button" {{on "click" this.confirmOverwrite}} class="gh-btn gh-btn-red" data-test-overwrite-button>
<span>Overwrite</span>
</button>
{{/if}}
{{#if this.canActivateTheme}}
<button type="button" {{on "click" this.activate}} class="gh-btn" data-test-activate-now-button>
<span>Activate{{#if this.validationErrors}} with errors{{/if}}</span>
</button>
{{/if}}
{{#if (or this.validationErrors this.fatalValidationErrors)}}
<button type="button" {{on "click" this.reset}} class="gh-btn gh-btn-black ml2" data-test-try-again-button>
<span>Retry</span>
</button>
{{/if}}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,164 @@
import Component from '@glimmer/component';
import {
UnsupportedMediaTypeError,
isThemeValidationError
} from 'ghost-admin/services/ajax';
import {action} from '@ember/object';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
export default class UploadThemeModalComponent extends Component {
@service eventBus;
@service ghostPaths;
@service store;
@service themeManagement;
@tracked displayOverwriteWarning = false;
@tracked file;
@tracked theme;
@tracked validationErrors;
@tracked validationWarnings;
@tracked fatalValidationErrors;
accept = ['application/zip', 'application/x-zip-compressed'];
extensions = ['zip'];
get themes() {
return this.store.peekAll('theme');
}
get currentThemeNames() {
return this.themes.map(theme => theme.name);
}
get themeName() {
let themePackage = this.theme.package;
let name = this.theme.name;
return themePackage ? `${themePackage.name} - ${themePackage.version}` : name;
}
get fileThemeName() {
return this.file?.name.replace(/\.zip$/, '');
}
get canActivateTheme() {
return this.theme && !this.theme.active;
}
get uploadUrl() {
return `${this.ghostPaths.apiRoot}/themes/upload/`;
}
get hasWarningsOrErrors() {
return this.validationWarnings?.length || this.validationErrors?.length;
}
get closeDisabled() {
return this.themeManagement.isUploading;
}
constructor() {
super(...arguments);
this.refreshThemesTask.perform();
}
@task
*refreshThemesTask() {
yield this.store.findAll('theme');
}
@action
validateTheme(file) {
const themeName = file.name.replace(/\.zip$/, '').replace(/[^\w@.]/gi, '-').toLowerCase();
this.file = file;
const [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
const extensions = this.extensions;
if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
return new UnsupportedMediaTypeError();
}
if (file.name.match(/^casper\.zip$/i)) {
return {payload: {errors: [{message: 'Sorry, the default Casper theme cannot be overwritten.<br>Please rename your zip file and try again.'}]}};
}
if (!this._allowOverwrite && this.currentThemeNames.includes(themeName)) {
this.displayOverwriteWarning = true;
return false;
}
return true;
}
@action
confirmOverwrite() {
this._allowOverwrite = true;
this.displayOverwriteWarning = false;
// we need to schedule afterRender so that the upload component is
// displayed again in order to subscribe/respond to the event bus
run.schedule('afterRender', this, function () {
this.eventBus.publish('themeUploader:upload', this.file);
});
}
@action
uploadSuccess(response) {
this.store.pushPayload(response);
const theme = this.store.peekRecord('theme', response.themes[0].name);
this.theme = theme;
if (theme.warnings?.length > 0) {
this.validationWarnings = theme.warnings;
}
// Ghost differentiates between errors and fatal errors
// You can't activate a theme with fatal errors, but with errors.
if (theme.errors?.length > 0) {
this.validationErrors = theme.errors;
}
}
@action
uploadFailed(errorResponse) {
if (isThemeValidationError(errorResponse)) {
const errors = errorResponse.payload.errors[0].details.errors;
const fatalErrors = [];
const normalErrors = [];
// to have a proper grouping of fatal errors and none fatal, we need to check
// our errors for the fatal property
errors.forEach?.((error) => {
if (error.fatal) {
fatalErrors.push(error);
} else {
normalErrors.push(errors[i]);
}
});
this.fatalValidationErrors = fatalErrors;
this.validationErrors = normalErrors;
}
}
@action
activate() {
this.themeManagement.activateTask.perform(this.theme);
this.args.close();
}
@action
reset() {
this.theme = null;
this.validationWarnings = [];
this.validationErrors = [];
this.fatalValidationErrors = [];
}
}

View File

@ -3,6 +3,7 @@ import {inject as service} from '@ember/service';
export default class AdvancedThemeSettingsController extends Controller {
@service store;
@service themeManagement;
get themes() {
return this.store.peekAll('theme');

View File

@ -1,6 +1,9 @@
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
export default class ChangeThemeController extends Controller {
@service themeManagement;
marketplaceThemes = [{
name: 'Edition',
category: 'Newsletter',

View File

@ -2,23 +2,32 @@ import EPMModalsService from 'ember-promise-modals/services/modals';
import {bind} from '@ember/runloop';
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/design/theme-errors': {
className: 'fullscreen-modal-action fullscreen-modal-wide'
},
'modals/limits/custom-theme': {
className: 'fullscreen-modal-action fullscreen-modal-wide'
}
};
export default class ModalsService extends EPMModalsService {
@service dropdown;
@service themeManagement;
DEFAULT_MODAL_OPTIONS = {
'modals/confirm-unsaved-changes': {
className: 'fullscreen-modal-action fullscreen-modal-wide'
},
'modals/design/upload-theme': {
className: 'fullscreen-modal-action fullscreen-modal-wide',
beforeClose: () => {
if (this.themeManagement.isUploading) {
return false;
}
}
},
'modals/design/confirm-delete-theme': {
className: 'fullscreen-modal-action fullscreen-modal-wide'
},
'modals/design/theme-errors': {
className: 'fullscreen-modal-action fullscreen-modal-wide'
},
'modals/limits/custom-theme': {
className: 'fullscreen-modal-action fullscreen-modal-wide'
}
}
// we manually close modals on backdrop clicks and escape rather than letting focus-trap
// handle it so we can intercept/abort closing for things like unsaved change confirmations
@ -27,7 +36,7 @@ export default class ModalsService extends EPMModalsService {
escapeDeactivates = false;
open(modal, data, options) {
const mergedOptions = Object.assign({}, DEFAULT_MODAL_OPTIONS[modal], options);
const mergedOptions = Object.assign({}, this.DEFAULT_MODAL_OPTIONS[modal], options);
return super.open(modal, data, mergedOptions);
}

View File

@ -1,5 +1,6 @@
import Service from '@ember/service';
import config from 'ghost-admin/config/environment';
import {action} from '@ember/object';
import {isEmpty} from '@ember/utils';
import {isThemeValidationError} from 'ghost-admin/services/ajax';
import {inject as service} from '@ember/service';
@ -14,8 +15,33 @@ export default class ThemeManagementService extends Service {
@service modals;
@service settings;
@tracked isUploading;
@tracked previewHtml;
@action
async upload(event) {
event?.preventDefault();
if (this.limit.limiter.isLimited('customThemes')) {
return this.modals.open('modals/limits/custom-theme');
}
try {
// Sending a bad string to make sure it fails (empty string isn't valid)
await this.limit.limiter.errorIfWouldGoOverLimit('customThemes', {value: '.'});
} catch (error) {
if (error.errorType === 'HostLimitError') {
return this.modals.open('modals/limit/custom-theme', {
message: error.message
});
}
throw error;
}
return this.modals.open('modals/design/upload-theme');
}
@task
*activateTask(theme) {
let resultModal = null;

View File

@ -1,6 +1,10 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<span class="gh-canvas-title">Advanced</span>
<section class="view-actions">
<button type="button" class="gh-btn gh-btn-black" {{on "click" this.themeManagement.upload}}><span>Upload theme</span></button>
</section>
</GhCanvasHeader>
<section class="view-container">

View File

@ -2,9 +2,11 @@
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>All themes</h2>
<section class="view-actions">
<a href="https://ghost.org/themes/" target="_blank" rel="noopener noreferrer" class="gh-btn gh-btn-grey gh-btn-icon-right">
<a href="https://ghost.org/themes/" target="_blank" rel="noopener noreferrer" class="mr4 gh-btn gh-btn-grey gh-btn-icon-right">
<span>Premium themes {{svg-jar "external"}}</span>
</a>
<button type="button" class="gh-btn gh-btn-black" {{on "click" this.themeManagement.upload}}><span>Upload theme</span></button>
</section>
</GhCanvasHeader>