Cleaned up `customThemeSettings` feature flag

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

- removed flag and labs screen toggle
- removed all conditionals
- removed all old/unused route/controller/component files
- renamed labs components and classes to non-labs naming
This commit is contained in:
Kevin Ansfield 2022-01-03 14:09:16 +00:00
parent 8969745c07
commit e5ae600efc
32 changed files with 93 additions and 1986 deletions

View File

@ -1,32 +0,0 @@
<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>
{{#unless theme.active}}
<button type="button" {{on "click" (fn this.activateTheme theme.model dd)}} class="apps-configured-action darkgrey apps-configured-action-activate green-hover green-bg-hover" data-test-button="activate">Activate</button>
{{/unless}}
<GhBasicDropdown @verticalPosition="below" @horizontalPosition="right" @buttonPosition="right" as |dd|>
<dd.Trigger class="gh-btn gh-btn-icon" data-test-button="actions"><span>{{svg-jar "dotdotdot"}}</span></dd.Trigger>
<dd.Content class="relative-dropdown-menu">
<ul class="dropdown-menu" data-test-actions-for={{theme.name}}>
<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="gh-list-delete" data-test-button="delete">Delete</button></li>
{{/if}}
</ul>
</dd.Content>
</GhBasicDropdown>
</div>
</div>
{{/each}}
</div>

View File

@ -1,99 +0,0 @@
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

@ -1,45 +1,32 @@
<div class="apps-grid" data-test-themes-list>
{{#if this.sortedThemes}}
{{#each this.sortedThemes as |theme|}}
<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-left">
<div class="apps-card-meta">
<h3 class="apps-card-app-title" data-test-theme-title>{{theme.label}}</h3>
{{#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>
</div>
<div class="apps-card-right">
<div class="apps-configured">
{{!--Delete--}}
{{#if theme.isDeletable}}
<a href="#" {{action this.deleteTheme theme}} disabled={{theme.active}} class="apps-configured-action darkgrey red-hover red-bg-hover" data-test-theme-delete-button>Delete</a>
{{/if}}
{{!--Download--}}
<a href="#" {{action this.downloadTheme theme}} class="apps-configured-action darkgrey darkgrey-hover lightgrey-bg-hover" data-test-theme-download-button>Download</a>
{{!--Active Label / Activate Button--}}
{{#if theme.active}}
<span class="gh-badge gh-badge-black apps-configured-action" data-test-theme-badge>Active</span>
{{else}}
<a href="#" {{action this.activateTheme theme.model}} class="apps-configured-action darkgrey apps-configured-action-activate green-hover green-bg-hover" data-test-theme-activate-button>
Activate
</a>
{{/if}}
</div>
</div>
</div>
</div>
{{/each}}
{{#unless theme.active}}
<button type="button" {{on "click" (fn this.activateTheme theme.model dd)}} class="apps-configured-action darkgrey apps-configured-action-activate green-hover green-bg-hover" data-test-button="activate">Activate</button>
{{/unless}}
<GhBasicDropdown @verticalPosition="below" @horizontalPosition="right" @buttonPosition="right" as |dd|>
<dd.Trigger class="gh-btn gh-btn-icon" data-test-button="actions"><span>{{svg-jar "dotdotdot"}}</span></dd.Trigger>
{{else}}
<div class="apps-card-app">
<div class="apps-card-left">
<div class="apps-card-meta">
<h3 class="apps-card-app-title">No themes found</h3>
<p class="apps-card-app-desc"><span class="description">Please upload a theme to continue</span></p>
<dd.Content class="relative-dropdown-menu">
<ul class="dropdown-menu" data-test-actions-for={{theme.name}}>
<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="gh-list-delete" data-test-button="delete">Delete</button></li>
{{/if}}
</ul>
</dd.Content>
</GhBasicDropdown>
</div>
</div>
</div>
{{/if}}
{{/each}}
</div>

View File

@ -1,13 +1,25 @@
import Component from '@ember/component';
import {computed} from '@ember/object';
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {get} from '@ember/object';
import {inject as service} from '@ember/service';
export default Component.extend({
export default class GhThemeTableComponent extends Component {
@service ghostPaths;
@service modals;
@service themeManagement;
@service utils;
themes: null,
activateTaskInstance = null;
confirmDeleteModal = null;
sortedThemes: computed('themes.@each.active', function () {
let themes = this.themes.map((t) => {
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');
@ -60,6 +72,28 @@ export default Component.extend({
}
return 0;
});
}).readOnly()
}
});
@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

@ -1,13 +0,0 @@
<header class="modal-header" data-test-delete-theme-modal>
<h1>Are you sure you want to delete this</h1>
</header>
<a class="close" href="" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
<p>You're about to delete "<strong>{{this.theme.label}}</strong>". This is permanent! We warned you, k? Maybe <a href="#" {{action this.download}}>download your theme before continuing</a></p>
</div>
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn" data-test-cancel-button><span>Cancel</span></button>
<GhTaskButton @buttonText="Delete" @successText="Deleted" @task={{this.deleteTheme}} @class="gh-btn gh-btn-red gh-btn-icon" data-test-delete-button="true" />
</div>

View File

@ -1,25 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {alias} from '@ember/object/computed';
import {task} from 'ember-concurrency';
export default ModalComponent.extend({
// Allowed actions
confirm: () => {},
theme: alias('model.theme'),
download: alias('model.download'),
actions: {
confirm() {
this.deleteTheme.perform();
}
},
deleteTheme: task(function* () {
try {
yield this.confirm();
} finally {
this.send('closeModal');
}
}).drop()
});

View File

@ -1,147 +0,0 @@
<div class="theme-validation-container" {{did-update this.reset @model}}>
<header class="modal-header" data-test-modal="install-theme">
<h1>
{{#if this.installSuccess}}
{{#if this.hasWarningsOrErrors}}
Install successful with {{#if this.validationErrors}}errors{{else}}warnings{{/if}}
{{else}}
Install successful!
{{/if}}
{{else if this.hasWarningsOrErrors}}
Invalid theme
{{else}}
Install theme
{{/if}}
</h1>
</header>
<button type="button" class="close" title="Close" {{on "click" this.close}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
{{#if this.isReady}}
<p>
You're about to install <strong>{{this.themeName}}</strong> from the theme directory.
</p>
{{#if this.willOverwriteExisting}}
<p>
This will overwrite your existing version of {{this.themeName}}{{if this.willOverwriteExisting.active " which is your active theme"}}.
Any custom changes will be lost.
</p>
{{/if}}
{{#if this.marketplaceTheme.shortImage}}
<div class="theme-validation-screenshot relative">
<img style="object-fit:contain;" src={{this.marketplaceTheme.shortImage}} alt="Edition Theme" />
</div>
{{/if}}
{{/if}}
{{#if this.willOverwriteDefault}}
<p>
Sorry, the default Casper theme cannot be overwritten.<br>
If you wish to make changes please download the theme and upload a renamed zip file.
</p>
{{/if}}
{{#if this.installSuccess}}
{{#if this.hasWarningsOrErrors}}
<p>
The theme <strong>"{{this.themeName}}"</strong> was installed successfully but we detected some {{if this.validationErrors "errors" "warnings"}}.
{{#unless this.theme.active}}
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.
{{/unless}}
</p>
{{else}}
{{!-- Installed with no errors --}}
<p>The theme <strong>"{{this.themeName}}"</strong> was installed successfully. {{unless this.theme.active "Do you want to activate it now?"}}</p>
{{/if}}
{{/if}}
{{#if this.installError}}
{{!-- Outright failure - not found, not a theme, server error, etc --}}
<p>{{this.themeName}} failed to install.</p>
<p class="error"><strong class="response">{{this.installError}}</strong></p>
{{/if}}
{{#if this.installFailure}}
{{!-- Invalid theme --}}
<p>This theme is invalid and cannot be activated. Contact the theme developer.</p>
{{/if}}
{{#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}}
{{#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}}
</div>
<div class="modal-footer">
<div class="flex items-center justify-between {{if this.hasActionButton "flex-auto"}}">
<button type="button" class="gh-btn" {{on "click" this.close}} data-test-button="cancel">
<span>{{#if (or this.installSuccess this.installFailure)}}Close{{else}}Cancel{{/if}}</span>
</button>
{{#if this.hasActionButton}}
<div class="flex items-center">
{{#if this.shouldShowInstall}}
<GhTaskButton
@task={{this.installTask}}
@type="button"
@class="gh-btn gh-btn-icon gh-btn-black"
@buttonText={{if this.willOverwriteExisting "Overwrite" "Install"}}
@runningText="Installing"
@successText="Installed"
data-test-button="install"
/>
{{/if}}
{{#if this.shouldShowActivate}}
<GhTaskButton
@task={{this.activateTask}}
@type="button"
@class="gh-btn gh-btn-icon gh-btn-black"
@buttonText="Activate"
@runningText="Activating"
data-test-button="activate"
/>
{{/if}}
</div>
{{/if}}
</div>
</div>
</div>

View File

@ -1,163 +0,0 @@
import ModalBase from 'ghost-admin/components/modal-base';
import classic from 'ember-classic-decorator';
import {action} from '@ember/object';
import {isThemeValidationError} from 'ghost-admin/services/ajax';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency-decorators';
import {tracked} from '@glimmer/tracking';
import {MARKETPLACE_THEMES} from 'ghost-admin/controllers/settings/theme';
// TODO: update modals to work fully with Glimmer components
@classic
export default class ModalInstallThemeComponent extends ModalBase {
@service ajax;
@service ghostPaths;
@service store;
@tracked model;
@tracked theme;
@tracked installError = '';
@tracked validationWarnings = [];
@tracked validationErrors = [];
@tracked fatalValidationErrors = [];
get themeName() {
return this.model.ref.split('/')[1];
}
get marketplaceTheme() {
return MARKETPLACE_THEMES.find(theme => theme.name.toLowerCase() === this.themeName.toLowerCase());
}
get currentThemeNames() {
return this.model.themes.mapBy('name');
}
get willOverwriteDefault() {
return this.themeName.toLowerCase() === 'casper';
}
get willOverwriteExisting() {
return this.model.themes.findBy('name', this.themeName.toLowerCase());
}
get installSuccess() {
return !!this.theme;
}
get installFailure() {
return !this.installSuccess && (this.validationErrors.length || this.fatalValidationErrors.length);
}
get isReady() {
return !this.installSuccess && !this.installError && !this.installFailure && !this.willOverwriteDefault;
}
get hasWarningsOrErrors() {
return this.validationWarnings.length > 0 || this.validationErrors.length > 0;
}
get shouldShowInstall() {
return !this.installSuccess && !this.installFailure && !this.willOverwriteDefault;
}
get shouldShowActivate() {
return this.installSuccess && !this.theme.active;
}
get hasActionButton() {
return this.shouldShowInstall || this.shouldShowActivate;
}
@action
close() {
this.closeModal();
}
@action
reset() {
this.theme = null;
this.resetErrors();
}
actions = {
confirm() {
// noop - needed to override ModalBase.actions.confirm
},
// needed because ModalBase uses .send() for keyboard events
closeModal() {
this.closeModal();
}
}
@task({drop: true})
*installTask() {
try {
const url = this.ghostPaths.url.api('themes/install') + `?source=github&ref=${this.model.ref}`;
const result = yield this.ajax.post(url);
this.installError = '';
if (result.themes) {
// show theme in list immediately
this.store.pushPayload(result);
this.theme = this.store.peekRecord('theme', result.themes[0].name);
this.validationWarnings = this.theme.warnings || [];
this.validationErrors = this.theme.errors || [];
this.fatalValidationErrors = [];
return true;
}
} catch (error) {
if (isThemeValidationError(error)) {
this.resetErrors();
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 && 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]);
}
}
}
this.fatalValidationErrors = fatalErrors;
this.validationErrors = normalErrors;
return false;
}
if (error.payload?.errors) {
this.installError = error.payload.errors[0].message;
return false;
}
this.installError = error.message;
throw error;
}
}
@task({drop: true})
*activateTask() {
yield this.theme.activate();
this.closeModal();
}
resetErrors() {
this.installError = '';
this.validationWarnings = [];
this.validationErrors = [];
this.fatalValidationErrors = [];
}
}

View File

@ -1,64 +0,0 @@
<div class="theme-validation-container" data-test-theme-warnings-modal="true">
<header class="modal-header">
<h1 data-test-theme-warnings-title>
{{#unless this.canActivate}}
{{this.title}}
{{else}}
{{this.title}} with {{#if this.errors}}errors{{else}}warnings{{/if}}
{{/unless}}
</h1>
</header>
<a class="close" href="#" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<div class="modal-body">
{{#if this.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 this.fatalErrors as |error|}}
<li class="theme-validation-item theme-fatal-error">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
{{#if this.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 this.errors as |error|}}
<li class="theme-validation-item theme-error">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
{{#if (and this.warnings (or this.fatalErrors this.errors))}}
<div>
<h2 class="mb0 mt4 f5 fw6">Warnings</h2>
</div>
{{/if}}
{{#if this.warnings}}
<ul class="pa0" data-test-theme-warnings>
{{#each this.warnings as |error|}}
<li class="theme-validation-item theme-warning">
<GhThemeErrorLi @error={{error}} />
</li>
{{/each}}
</ul>
{{/if}}
</div>
</div>
<div class="modal-footer">
<button {{action "closeModal"}} class="gh-btn" data-test-modal-close-button>
<span>Ok</span>
</button>
</div>

View File

@ -1,17 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {reads} from '@ember/object/computed';
export default ModalComponent.extend({
title: reads('model.title'),
message: reads('model.message'),
warnings: reads('model.warnings'),
errors: reads('model.errors'),
fatalErrors: reads('model.fatalErrors'),
canActivate: reads('model.canActivate'),
actions: {
confirm() {
this.send('closeModal');
}
}
});

View File

@ -1,25 +0,0 @@
<header class="modal-header" data-test-modal="delete-user">
<h1>Upgrade to enable custom themes</h1>
</header>
<button class="close" title="Close" {{on "click" this.closeModal}}>{{svg-jar "close"}}<span class="hidden">Close</span></button>
<div class="modal-body">
<p>
{{#if this.model.limitErrorMessage}}
{{html-safe this.model.limitErrorMessage}}
{{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" this.closeModal}} class="gh-btn" data-test-button="cancel-upgrade">
<span>Cancel</span>
</button>
<button {{on "click" (action "upgrade")}} class="gh-btn gh-btn-green" data-test-button="upgrade-plan">
<span>Upgrade</span>
</button>
</div>

View File

@ -1,16 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import {inject as service} from '@ember/service';
export default ModalComponent.extend({
router: service(),
actions: {
upgrade() {
this.router.transitionTo('pro');
},
confirm() {
this.send('upgrade');
}
}
});

View File

@ -1,138 +0,0 @@
<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>
<a class="close" href="#" role="button" title="Close" {{action "closeModal"}}>{{svg-jar "close"}}<span class="hidden">Close</span></a>
<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={{action "validateTheme"}}
@uploadStarted={{action "uploadStarted"}}
@uploadFinished={{action "uploadFinished"}}
@uploadSuccess={{action "uploadSuccess"}}
@uploadFailed={{action "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 {{action "closeModal"}} 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 {{action "confirmOverwrite"}} class="gh-btn gh-btn-red" data-test-overwrite-button>
<span>Overwrite</span>
</button>
{{/if}}
{{#if this.canActivateTheme}}
<button {{action "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 {{action "reset"}} class="gh-btn gh-btn-black ml2" data-test-try-again-button>
<span>Retry</span>
</button>
{{/if}}
</div>
</div>
</div>

View File

@ -1,176 +0,0 @@
import ModalComponent from 'ghost-admin/components/modal-base';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {
UnsupportedMediaTypeError,
isThemeValidationError
} from 'ghost-admin/services/ajax';
import {computed} from '@ember/object';
import {get} from '@ember/object';
import {mapBy, or} from '@ember/object/computed';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
const DEFAULTS = {
accept: ['application/zip', 'application/x-zip-compressed'],
extensions: ['zip']
};
export default ModalComponent.extend({
eventBus: service(),
store: service(),
accept: null,
extensions: null,
themes: null,
closeDisabled: false,
file: null,
theme: false,
displayOverwriteWarning: false,
hideUploader: or('theme', 'displayOverwriteWarning'),
currentThemeNames: mapBy('model.themes', 'name'),
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/themes/upload/`;
}),
themeName: computed('theme.{name,package.name}', function () {
let themePackage = this.get('theme.package');
let name = this.get('theme.name');
return themePackage ? `${themePackage.name} - ${themePackage.version}` : name;
}),
fileThemeName: computed('file', function () {
let file = this.file;
return file.name.replace(/\.zip$/, '');
}),
canActivateTheme: computed('theme', function () {
let theme = this.theme;
return theme && !theme.get('active');
}),
init() {
this._super(...arguments);
this.accept = this.accept || DEFAULTS.accept;
this.extensions = this.extensions || DEFAULTS.extensions;
},
actions: {
validateTheme(file) {
let themeName = file.name.replace(/\.zip$/, '').replace(/[^\w@.]/gi, '-').toLowerCase();
let currentThemeNames = this.currentThemeNames;
this.set('file', file);
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
let 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 && currentThemeNames.includes(themeName)) {
this.set('displayOverwriteWarning', true);
return false;
}
return true;
},
confirmOverwrite() {
this._allowOverwrite = true;
this.set('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);
});
},
uploadStarted() {
this.set('closeDisabled', true);
},
uploadFinished() {
this.set('closeDisabled', false);
},
uploadSuccess(response) {
this.store.pushPayload(response);
let theme = this.store.peekRecord('theme', response.themes[0].name);
this.set('theme', theme);
if (get(theme, 'warnings.length') > 0) {
this.set('validationWarnings', get(theme, 'warnings'));
}
// Ghost differentiates between errors and fatal errors
// You can't activate a theme with fatal errors, but with errors.
if (get(theme, 'errors.length') > 0) {
this.set('validationErrors', get(theme, 'errors'));
}
this.set('hasWarningsOrErrors', this.get('validationErrors.length') || this.get('validationWarnings.length'));
// invoke the passed in confirm action
this.get('model.uploadSuccess')(theme);
},
uploadFailed(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 && 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]);
}
}
}
this.set('fatalValidationErrors', fatalErrors);
this.set('validationErrors', normalErrors);
}
},
confirm() {
// noop - we don't want the enter key doing anything
},
activate() {
this.get('model.activate')(this.theme);
this.closeModal();
},
closeModal() {
if (!this.closeDisabled) {
this._super(...arguments);
}
},
reset() {
this.set('theme', null);
this.set('validationWarnings', []);
this.set('validationErrors', []);
this.set('fatalValidationErrors', []);
this.set('hasWarningsOrErrors', false);
}
}
});

View File

@ -1,169 +0,0 @@
/* eslint-disable ghost/ember/alias-model-in-controller */
import Controller from '@ember/controller';
import {isEmpty} from '@ember/utils';
import {isThemeValidationError} from 'ghost-admin/services/ajax';
import {notEmpty} from '@ember/object/computed';
import {inject as service} from '@ember/service';
export const MARKETPLACE_THEMES = [{
name: 'Edition',
category: 'Newsletter',
url: 'https://github.com/TryGhost/Edition',
previewUrl: 'https://ghost.org/themes/edition',
ref: 'TryGhost/Edition',
image: 'assets/img/themes/Edition.jpg',
shortImage: 'assets/img/themes/Edition-cut.jpg'
}, {
name: 'Alto',
category: 'Blog',
url: 'https://github.com/TryGhost/Alto',
previewUrl: 'https://ghost.org/themes/alto',
ref: 'TryGhost/Alto',
image: 'assets/img/themes/Alto.jpg',
shortImage: 'assets/img/themes/Alto-cut.jpg'
}, {
name: 'London',
category: 'Photography',
url: 'https://github.com/TryGhost/London',
previewUrl: 'https://ghost.org/themes/london',
ref: 'TryGhost/London',
image: 'assets/img/themes/London.jpg',
shortImage: 'assets/img/themes/London-cut.jpg'
}, {
name: 'Ease',
category: 'Documentation',
url: 'https://github.com/TryGhost/Ease',
previewUrl: 'https://ghost.org/themes/ease',
ref: 'TryGhost/Ease',
image: 'assets/img/themes/Ease.jpg',
shortImage: 'assets/img/themes/Ease-cut.jpg'
}];
export default Controller.extend({
config: service(),
ghostPaths: service(),
limit: service(),
notifications: service(),
session: service(),
settings: service(),
utils: service(),
dirtyAttributes: false,
newNavItem: null,
newSecondaryNavItem: null,
themes: null,
themeToDelete: null,
displayUpgradeModal: false,
limitErrorMessage: null,
init() {
this._super(...arguments);
this.marketplaceThemes = MARKETPLACE_THEMES;
},
showDeleteThemeModal: notEmpty('themeToDelete'),
actions: {
async activateTheme(theme) {
const isOverLimit = await this.limit.checkWouldGoOverLimit('customThemes', {value: theme.name});
if (isOverLimit) {
try {
await this.limit.limiter.errorIfWouldGoOverLimit('customThemes', {value: theme.name});
this.limitErrorMessage = null;
} catch (error) {
if (error.errorType !== 'HostLimitError') {
throw error;
}
this.limitErrorMessage = error.message;
}
this.set('displayUpgradeModal', true);
return;
}
return theme.activate().then((activatedTheme) => {
if (!isEmpty(activatedTheme.get('warnings'))) {
this.set('themeWarnings', activatedTheme.get('warnings'));
this.set('showThemeWarningsModal', true);
}
if (!isEmpty(activatedTheme.get('errors'))) {
this.set('themeErrors', activatedTheme.get('errors'));
this.set('showThemeWarningsModal', true);
}
}).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]);
}
}
}
this.set('themeErrors', normalErrors);
this.set('themeFatalErrors', fatalErrors);
this.set('showThemeErrorsModal', true);
return;
}
throw error;
});
},
downloadTheme(theme) {
this.utils.downloadFile(`${this.get('ghostPaths.apiRoot')}/themes/${theme.name}/download/`);
},
deleteTheme(theme) {
if (theme) {
return this.set('themeToDelete', theme);
}
return this._deleteTheme();
},
hideDeleteThemeModal() {
this.set('themeToDelete', null);
},
hideThemeWarningsModal() {
this.set('themeWarnings', null);
this.set('themeErrors', null);
this.set('themeFatalErrors', null);
this.set('showThemeWarningsModal', false);
this.set('showThemeErrorsModal', false);
},
hideUpgradeModal() {
this.set('displayUpgradeModal', false);
},
reset() {}
},
_deleteTheme() {
let theme = this.store.peekRecord('theme', this.themeToDelete.name);
if (!theme) {
return;
}
return theme.destroyRecord().then(() => {
// HACK: this is a private method, we need to unload from the store
// here so that uploading another theme with the same "id" doesn't
// attempt to update the deleted record
theme.unloadRecord();
}).catch((error) => {
this.notifications.showAPIError(error);
});
}
});

View File

@ -1,18 +0,0 @@
import Controller from '@ember/controller';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class InstallThemeController extends Controller {
@service router;
queryParams = ['source', 'ref'];
@tracked source = '';
@tracked ref = '';
@action
close() {
this.router.transitionTo('settings.theme');
}
}

View File

@ -1,10 +0,0 @@
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
export default class UploadThemeController extends Controller {
@service limit;
get isAllowed() {
return !this.limit.limiter.isLimited('customThemes');
}
}

View File

@ -74,12 +74,6 @@ Router.map(function () {
this.route('settings.integrations.unsplash', {path: '/settings/integrations/unsplash'});
this.route('settings.integrations.zapier', {path: '/settings/integrations/zapier'});
// TODO: remove in customThemeSettings cleanup
// this.route('settings.theme', {path: '/settings/theme'}, function () {
// this.route('uploadtheme');
// this.route('install');
// });
this.route('settings.navigation', {path: '/settings/navigation'});
this.route('settings.labs', {path: '/settings/labs'});

View File

@ -15,10 +15,6 @@ export default class SettingsDesignRoute extends AuthenticatedRoute {
if (!this.session.user.isAdmin) {
return this.transitionTo('site');
}
if (!this.feature.customThemeSettings) {
return this.transitionTo('settings');
}
}
model() {

View File

@ -1,42 +0,0 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import CurrentUserSettings from 'ghost-admin/mixins/current-user-settings';
import RSVP from 'rsvp';
import {inject as service} from '@ember/service';
export default AuthenticatedRoute.extend(CurrentUserSettings, {
feature: service(),
settings: service(),
beforeModel() {
this._super(...arguments);
this.transitionAuthor(this.session.user);
if (this.feature.customThemeSettings) {
this.transitionTo('settings.design');
}
},
model() {
return RSVP.hash({
settings: this.settings.reload(),
themes: this.store.findAll('theme')
});
},
setupController(controller) {
controller.set('themes', this.store.peekAll('theme'));
this.controller.send('reset');
},
actions: {
activateTheme(theme) {
return this.controller.send('activateTheme', theme);
}
},
buildRouteInfoMetadata() {
return {
titleToken: 'Settings - Theme'
};
}
});

View File

@ -49,7 +49,6 @@ export default Service.extend({
nightShift: feature('nightShift', {user: true, onChange: '_setAdminTheme'}),
multipleProducts: feature('multipleProducts'),
oauthLogin: feature('oauthLogin'),
customThemeSettings: feature('customThemeSettings'),
membersActivity: feature('membersActivity'),
cardSettingsPanel: feature('cardSettingsPanel'),
membersAutoLogin: feature('membersAutoLogin'),

View File

@ -537,19 +537,15 @@ input:focus,
background-color: var(--black-90);
}
.td-cta-box {
background: #191b1f;
}
.td-item-empty {
background: var(--whitegrey-l1);
}
.gh-themes-container-labs {
.gh-themes-container {
background: var(--whitegrey-l2);
}
.gh-themes-container-labs .apps-grid {
.gh-themes-container .apps-grid {
background: none;
}

View File

@ -405,19 +405,6 @@
stroke-width: 4px;
}
.gh-theme-directory-container {
padding: 25px 0 0;
}
.theme-directory {
display: grid;
justify-content: space-between;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-gap: 25px;
max-width: 1320px;
margin: 0 0 4vw;
}
.td-item {
display: flex;
flex-direction: column;
@ -505,22 +492,7 @@
width: 80px;
}
@media (max-width: 1200px) {
.td-cta {
grid-template-columns: 1fr;
}
}
@media (max-width: 1100px) {
.theme-directory {
grid-template-columns: 1fr 1fr 1fr;
}
}
@media (max-width: 1000px) {
.theme-directory {
grid-template-columns: 1fr 1fr 1fr;
}
.td-item:nth-child(4),
.td-item:nth-child(5),
.td-item:nth-child(6) {
@ -528,16 +500,6 @@
}
}
@media (max-width: 600px) {
.theme-directory {
grid-template-columns: 1fr 1fr;
margin-bottom: 25px;
}
.td-cta {
margin: 50px 0;
}
}
/* General
/* ---------------------------------------------------------- */
@ -1713,29 +1675,29 @@ p.theme-validation-details {
stroke: var(--darkgrey);
}
.gh-themes-container-labs {
.gh-themes-container {
margin-bottom: 40px;
background: var(--main-color-content-greybg);
border-radius: var(--border-radius);
}
.gh-themes-container-labs .apps-grid-cell {
.gh-themes-container .apps-grid-cell {
background: none;
}
.gh-themes-container-labs .apps-grid-cell:hover {
.gh-themes-container .apps-grid-cell:hover {
background: var(--whitegrey-l1);
}
.gh-themes-container-labs .apps-card-app {
.gh-themes-container .apps-card-app {
padding: 16px 24px;
}
.gh-themes-container-labs .apps-grid-cell:last-of-type .apps-card-app {
.gh-themes-container .apps-grid-cell:last-of-type .apps-card-app {
border-bottom: none;
}
.gh-themes-container-labs .apps-configured-action {
.gh-themes-container .apps-configured-action {
display: block;
margin-right: 16px;
padding: 2px 6px;
@ -1743,15 +1705,15 @@ p.theme-validation-details {
border-radius: var(--border-radius);
}
.gh-themes-container-labs .gh-btn-icon {
.gh-themes-container .gh-btn-icon {
background: none;
}
.gh-themes-container-labs .gh-btn-icon:hover {
.gh-themes-container .gh-btn-icon:hover {
background: var(--whitegrey-d1);
}
.gh-themes-container-labs .gh-btn-icon svg {
.gh-themes-container .gh-btn-icon svg {
margin-right: 0;
}
@ -1760,19 +1722,19 @@ p.theme-validation-details {
}
@media (max-width: 500px) {
.gh-themes-container-labs .apps-configured {
.gh-themes-container .apps-configured {
justify-content: flex-end;
}
.gh-themes-container-labs .apps-card-meta {
.gh-themes-container .apps-card-meta {
flex-basis: auto;
}
}
.gh-theme-directory-container-labs {
.gh-theme-directory-container {
padding: 8px 0 0;
}
.theme-directory-labs {
.theme-directory {
display: grid;
justify-content: space-between;
grid-template-columns: 1fr 1fr 1fr;
@ -1782,32 +1744,32 @@ p.theme-validation-details {
}
@media (min-width: 1800px) {
.theme-directory-labs {
.theme-directory {
grid-template-columns: 1fr 1fr 1fr 1fr;
}
}
@media (max-width: 1120px) {
.theme-directory-labs {
.theme-directory {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 800px) and (max-width: 890px) {
.theme-directory-labs {
.theme-directory {
grid-template-columns: 1fr;
}
}
@media (max-width: 800px) {
.theme-directory-labs {
.theme-directory {
grid-column-gap: 32px;
grid-row-gap: 48px;
}
}
@media (max-width: 430px) {
.theme-directory-labs {
.theme-directory {
grid-template-columns: 1fr;
}
}
@ -1862,7 +1824,7 @@ p.theme-validation-details {
border-radius: 0 0 3px 3px;
}
.theme-directory-labs .td-item-desc {
.theme-directory .td-item-desc {
display: flex;
flex-direction: column;
}

View File

@ -164,7 +164,7 @@
<div class="gh-dashboard-container col-2">
<div class="gh-dashboard-box">
<div class="content">
<h2>Customize your site{{unless this.feature.customThemeSettings " design"}}</h2>
<h2>Customize your site</h2>
<p>Stand out from the crowd. Ghost lets you customize everything so you can create a publication that doesnt just look the same as what everyone else has.</p>
</div>
<div class="footer">

View File

@ -9,13 +9,13 @@
<section class="view-container">
{{#liquid-if this.showAdvanced}}
<div class="gh-themes-container-labs">
<GhThemeTableLabs @themes={{this.themes}} />
<div class="gh-themes-container">
<GhThemeTable @themes={{this.themes}} />
</div>
{{/liquid-if}}
<div class="gh-theme-directory-container-labs">
<div class="theme-directory-labs">
<div class="gh-theme-directory-container">
<div class="theme-directory">
{{#each this.themesList as |theme|}}
<LinkTo @route="settings.design.change-theme.view" @model={{theme.name}} class="td-item td-item-labs" data-test-theme-link={{theme.name}}>
<div class="gh-theme-browser">

View File

@ -248,19 +248,6 @@
{{/if}}
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Custom theme settings</h4>
<p class="gh-expandable-description">
Redesign of "Design" settings in Admin and allow themes to specify custom settings.
</p>
</div>
<div class="for-switch">
<GhFeatureFlag @flag="customThemeSettings" />
</div>
</div>
</div>
<div class="gh-expandable-block">
<div class="gh-expandable-header">
<div>

View File

@ -1,105 +0,0 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<h2 class="gh-canvas-title" data-test-screen-title>
<LinkTo @route="settings">Settings</LinkTo>
<span>{{svg-jar "arrow-right"}}</span>
Theme
</h2>
</GhCanvasHeader>
<section class="view-container">
<div class="gh-setting-header gh-first-header flex justify-between">
<span>Ghost theme directory</span>
<a href="https://ghost.org/themes/" target="_blank" rel="noopener noreferrer" class="gh-td-marketplace">
<span>View more {{svg-jar "arrow-right-small"}}</span>
</a>
</div>
<div class="gh-theme-directory-container">
<div class="theme-directory">
{{#each this.marketplaceThemes as |theme|}}
<a class="td-item" href={{theme.url}} target="_blank" rel="noopener noreferrer">
<div class="td-item-screenshot relative">
<img style="object-fit:contain;" src={{theme.image}} alt="{{theme.name}} Theme" />
<div class="td-item-overlay">
<LinkTo class="td-item-action gh-btn gh-btn-black mb4" @route="settings.theme.install" @query={{hash source="github" ref=theme.ref}}><span>Install</span></LinkTo>
<a href={{theme.previewUrl}} class="td-item-action gh-btn" target="_blank" rel="noopener"><span>Preview</span></a>
</div>
</div>
<div class="td-item-desc">
<div>{{theme.name}}</div>
<span class="td-item-category">• {{theme.category}}</span>
</div>
</a>
{{/each}}
</div>
</div>
<div class="gh-setting-header">Installed Themes</div>
<div class="gh-themes-container">
<GhThemeTable
@themes={{this.themes}}
@activateTheme={{action "activateTheme"}}
@downloadTheme={{action "downloadTheme"}}
@deleteTheme={{action "deleteTheme"}} />
<div class="flex justify-between mt6">
<LinkTo id="upload-theme" @route="settings.theme.uploadtheme" class="gh-btn gh-btn-green" data-test-button="uploadtheme">
<span>Upload a theme</span>
</LinkTo>
<a href="https://ghost.org/docs/themes/" target=_"blank" rel="noopener noreferrer" class="gh-btn gh-btn-outline">
<span>Theme developer docs</span>
</a>
</div>
{{#if this.showDeleteThemeModal}}
<GhFullscreenModal @modal="delete-theme"
@model={{hash
theme=this.themeToDelete
download=(action "downloadTheme" this.themeToDelete)
}}
@close={{action "hideDeleteThemeModal"}}
@confirm={{action "deleteTheme"}}
@modifier="action wide" />
{{/if}}
{{#if this.showThemeWarningsModal}}
<GhFullscreenModal @modal="theme-warnings"
@model={{hash
title="Activation successful"
warnings=this.themeWarnings
errors=this.themeErrors
canActivate=true
}}
@close={{action "hideThemeWarningsModal"}}
@modifier="action wide" />
{{/if}}
{{#if this.showThemeErrorsModal}}
<GhFullscreenModal @modal="theme-warnings"
@model={{hash
title="Activation failed"
errors=this.themeErrors
fatalErrors=this.themeFatalErrors
canActivate=false
}}
@close={{action "hideThemeWarningsModal"}}
@modifier="action wide" />
{{/if}}
</div>
</section>
</section>
{{outlet}}
{{#if this.displayUpgradeModal}}
<GhFullscreenModal @modal="upgrade-host-limit-custom-theme"
@model={{hash
limitErrorMessage=limitErrorMessage
}}
@close={{action "hideUpgradeModal"}}
@modifier="action wide" />
{{/if}}

View File

@ -1,10 +0,0 @@
<GhFullscreenModal
@modal="install-theme"
@model={{hash
source=this.source
ref=this.ref
themes=@model
}}
@close={{this.close}}
@modifier="action wide"
/>

View File

@ -1,16 +0,0 @@
{{#if this.isAllowed}}
<GhFullscreenModal @modal="upload-theme"
@model={{hash
themes=this.themes
activate=(route-action 'activateTheme')
}}
@close={{route-action "cancel"}}
@modifier="action wide" />
{{else}}
<GhFullscreenModal @modal="upgrade-host-limit-custom-theme"
@model={{hash
limitErrorMessage=limitErrorMessage
}}
@close={{route-action "cancel"}}
@modifier="action wide" />
{{/if}}

View File

@ -1,5 +1,4 @@
import Mirage from 'ember-cli-mirage';
import enableLabsFlag from '../helpers/enable-labs-flag';
import {authenticateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {click, currentRouteName, fillIn, find, findAll, visit} from '@ember/test-helpers';
@ -109,8 +108,6 @@ describe('Acceptance: Error Handling', function () {
});
it('handles ember-ajax HTML response', async function () {
enableLabsFlag(this.server, 'customThemeSettings');
this.server.del('/themes/foo/', htmlErrorResponse);
await visit('/settings/design/change-theme');

View File

@ -1,4 +1,3 @@
import enableLabsFlag from '../../helpers/enable-labs-flag';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {click, currentURL, find, findAll} from '@ember/test-helpers';
import {expect} from 'chai';
@ -12,8 +11,6 @@ describe('Acceptance: Settings - Design', function () {
setupMirage(hooks);
beforeEach(async function () {
enableLabsFlag(this.server, 'customThemeSettings');
let role = this.server.create('role', {name: 'Administrator'});
this.server.create('user', {roles: [role]});

View File

@ -1,557 +0,0 @@
import Mirage from 'ember-cli-mirage';
import mockThemes from 'ghost-admin/mirage/config/themes';
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {click, currentRouteName, currentURL, find, findAll} from '@ember/test-helpers';
import {expect} from 'chai';
import {fileUpload} from '../../helpers/file-upload';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
import {visit} from '../../helpers/visit';
// TODO: remove along with customThemeSettings feature flag removal
describe.skip('Acceptance: Settings - Theme', function () {
let hooks = setupApplicationTest();
setupMirage(hooks);
it('redirects to signin when not authenticated', async function () {
await invalidateSession();
await visit('/settings/theme');
expect(currentURL(), 'currentURL').to.equal('/signin');
});
it('redirects to staff page when authenticated as contributor', async function () {
let role = this.server.create('role', {name: 'Contributor'});
this.server.create('user', {roles: [role], slug: 'test-user'});
await authenticateSession();
await visit('/settings/theme');
expect(currentURL(), 'currentURL').to.equal('/settings/staff/test-user');
});
it('redirects to staff page when authenticated as author', async function () {
let role = this.server.create('role', {name: 'Author'});
this.server.create('user', {roles: [role], slug: 'test-user'});
await authenticateSession();
await visit('/settings/theme');
expect(currentURL(), 'currentURL').to.equal('/settings/staff/test-user');
});
describe('when logged in', function () {
beforeEach(async function () {
let role = this.server.create('role', {name: 'Administrator'});
this.server.create('user', {roles: [role]});
await authenticateSession();
});
it('can visit /settings/theme', async function () {
await visit('/settings/theme');
expect(currentRouteName()).to.equal('settings.theme.index');
});
it('allows management of themes', async function () {
// lists available themes + active theme is highlighted
// theme upload
// - displays modal
// - validates mime type
// - validates casper.zip
// - handles validation errors
// - handles upload and close
// - handles upload and activate
// - displays overwrite warning if theme already exists
// theme activation
// - switches theme
// theme deletion
// - displays modal
// - deletes theme and refreshes list
this.server.loadFixtures('themes');
await visit('/settings/theme');
// lists available themes (themes are specified in mirage/fixtures/settings)
expect(
findAll('[data-test-theme-id]').length,
'shows correct number of themes'
).to.equal(3);
expect(
find('[data-test-theme-active="true"] [data-test-theme-title]').textContent.trim(),
'Blog theme marked as active'
).to.equal('Blog (default)');
// theme upload displays modal
await click('[data-test-button="uploadtheme"]');
expect(
findAll('[data-test-modal="upload-theme"]').length,
'theme upload modal displayed after button click'
).to.equal(1);
// cancelling theme upload closes modal
await click('.fullscreen-modal [data-test-close-button]');
expect(
findAll('.fullscreen-modal').length === 0,
'upload theme modal is closed when cancelling'
).to.be.true;
// theme upload validates mime type
await click('[data-test-button="uploadtheme"]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {type: 'text/csv'});
expect(
find('.fullscreen-modal .failed').textContent,
'validation error is shown for invalid mime type'
).to.match(/is not supported/);
// theme upload validates casper.zip
await click('[data-test-upload-try-again-button]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'casper.zip', type: 'application/zip'});
expect(
find('.fullscreen-modal .failed').textContent,
'validation error is shown when uploading casper.zip'
).to.match(/default Casper theme cannot be overwritten/);
// theme upload handles upload errors
this.server.post('/themes/upload/', function () {
return new Mirage.Response(422, {}, {
errors: [{
message: 'Invalid theme'
}]
});
});
await click('[data-test-upload-try-again-button]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'error.zip', type: 'application/zip'});
expect(
find('.fullscreen-modal .failed').textContent.trim(),
'validation error is passed through from server'
).to.equal('Invalid theme');
// reset to default mirage handlers
mockThemes(this.server);
// theme upload handles validation errors
this.server.post('/themes/upload/', function () {
return new Mirage.Response(422, {}, {
errors: [
{
message: 'Theme is not compatible or contains errors.',
type: 'ThemeValidationError',
details: {
errors: [{
level: 'error',
rule: 'Assets such as CSS & JS must use the <code>{{asset}}</code> helper',
details: '<p>The listed files should be included using the <code>{{asset}}</code> helper.</p>',
failures: [
{
ref: '/assets/javascripts/ui.js'
}
]
}, {
level: 'error',
rule: 'Templates must contain valid Handlebars.',
failures: [
{
ref: 'index.hbs',
message: 'The partial index_meta could not be found'
},
{
ref: 'tag.hbs',
message: 'The partial index_meta could not be found'
}
]
}]
}
}
]
});
});
await click('[data-test-upload-try-again-button]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'bad-theme.zip', type: 'application/zip'});
expect(
find('.fullscreen-modal h1').textContent.trim(),
'modal title after uploading invalid theme'
).to.equal('Invalid theme');
expect(
findAll('.theme-validation-rule-text')[1].textContent,
'top-level errors are displayed'
).to.match(/Templates must contain valid Handlebars/);
await click('[data-test-toggle-details]');
expect(
find('.theme-validation-details').textContent,
'top-level errors do not escape HTML'
).to.match(/The listed files should be included using the {{asset}} helper/);
expect(
find('.theme-validation-list ul li').textContent,
'individual failures are displayed'
).to.match(/\/assets\/javascripts\/ui\.js/);
// reset to default mirage handlers
mockThemes(this.server);
await click('.fullscreen-modal [data-test-try-again-button]');
expect(
findAll('.theme-validation-errors').length,
'"Try Again" resets form after theme validation error'
).to.equal(0);
expect(
findAll('.gh-image-uploader').length,
'"Try Again" resets form after theme validation error'
).to.equal(1);
expect(
find('.fullscreen-modal h1').textContent.trim(),
'"Try Again" resets form after theme validation error'
).to.equal('Upload a theme');
// theme upload handles validation warnings
this.server.post('/themes/upload/', function ({themes}) {
let theme = {
name: 'blackpalm',
package: {
name: 'BlackPalm',
version: '1.0.0'
}
};
themes.create(theme);
theme.warnings = [{
level: 'warning',
rule: 'Assets such as CSS & JS must use the <code>{{asset}}</code> helper',
details: '<p>The listed files should be included using the <code>{{asset}}</code> helper. For more information, please see the <a href="https://ghost.org/docs/themes/helpers/asset/">asset helper documentation</a>.</p>',
failures: [
{
ref: '/assets/dist/img/apple-touch-icon.png'
},
{
ref: '/assets/dist/img/favicon.ico'
},
{
ref: '/assets/dist/css/blackpalm.min.css'
},
{
ref: '/assets/dist/js/blackpalm.min.js'
}
],
code: 'GS030-ASSET-REQ'
}];
return new Mirage.Response(200, {}, {
themes: [theme]
});
});
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'warning-theme.zip', type: 'application/zip'});
expect(
find('.fullscreen-modal h1').textContent.trim(),
'modal title after uploading theme with warnings'
).to.equal('Upload successful with warnings');
await click('[data-test-toggle-details]');
expect(
find('.theme-validation-details').textContent,
'top-level warnings are displayed'
).to.match(/The listed files should be included using the {{asset}} helper/);
expect(
find('.theme-validation-list ul li').textContent,
'individual warning failures are displayed'
).to.match(/\/assets\/dist\/img\/apple-touch-icon\.png/);
// reset to default mirage handlers
mockThemes(this.server);
await click('.fullscreen-modal [data-test-close-button]');
// theme upload handles success then close
await click('[data-test-button="uploadtheme"]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'theme-1.zip', type: 'application/zip'});
expect(
find('.fullscreen-modal h1').textContent.trim(),
'modal header after successful upload'
).to.equal('Upload successful!');
expect(
find('.modal-body').textContent,
'modal displays theme name after successful upload'
).to.match(/"Test 1 - 0\.1" uploaded successfully/);
expect(
findAll('[data-test-theme-id]').length,
'number of themes in list grows after upload'
).to.equal(5);
expect(
find('[data-test-theme-active="true"] [data-test-theme-title]').textContent.trim(),
'newly uploaded theme is not active'
).to.equal('Blog (default)');
await click('.fullscreen-modal [data-test-close-button]');
// theme upload handles success then activate
await click('[data-test-button="uploadtheme"]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'theme-2.zip', type: 'application/zip'});
await click('.fullscreen-modal [data-test-activate-now-button]');
expect(
findAll('[data-test-theme-id]').length,
'number of themes in list grows after upload and activate'
).to.equal(6);
expect(
find('[data-test-theme-active="true"] [data-test-theme-title]').textContent.trim(),
'newly uploaded+activated theme is active'
).to.equal('Test 2');
// theme activation switches active theme
await click('[data-test-theme-id="casper"] [data-test-theme-activate-button]');
expect(
find('[data-test-theme-id="test-2"] .apps-card-app').classList.contains('theme-list-item--active'),
'previously active theme is not active'
).to.be.false;
expect(
find('[data-test-theme-id="casper"] .apps-card-app').classList.contains('theme-list-item--active'),
'activated theme is active'
).to.be.true;
// theme activation shows errors
this.server.put('themes/:theme/activate', function () {
return new Mirage.Response(422, {}, {
errors: [
{
message: 'Theme is not compatible or contains errors.',
type: 'ThemeValidationError',
details: {
checkedVersion: '2.x',
name: 'casper',
version: '2.9.7',
errors: [{
level: 'error',
rule: 'Assets such as CSS & JS must use the <code>{{asset}}</code> helper',
details: '<p>The listed files should be included using the <code>{{asset}}</code> helper.</p>',
failures: [
{
ref: '/assets/javascripts/ui.js'
}
]
}, {
level: 'error',
fatal: true,
rule: 'Templates must contain valid Handlebars.',
failures: [
{
ref: 'index.hbs',
message: 'The partial index_meta could not be found'
},
{
ref: 'tag.hbs',
message: 'The partial index_meta could not be found'
}
]
}]
}
}
]
});
});
await click('[data-test-theme-id="test-2"] [data-test-theme-activate-button]');
expect(find('[data-test-theme-warnings-modal]')).to.exist;
expect(
find('[data-test-theme-warnings-title]').textContent.trim(),
'modal title after activating invalid theme'
).to.equal('Activation failed');
expect(
find('[data-test-theme-fatal-errors]').textContent,
'top-level errors are displayed in activation errors'
).to.match(/Templates must contain valid Handlebars/);
await click('[data-test-theme-errors] [data-test-toggle-details]');
expect(
find('[data-test-theme-errors] .theme-validation-details').textContent,
'top-level errors do not escape HTML in activation errors'
).to.match(/The listed files should be included using the {{asset}} helper/);
expect(
find('.theme-validation-list ul li').textContent,
'individual failures are displayed in activation errors'
).to.match(/\/assets\/javascripts\/ui\.js/);
// restore default mirage handlers
mockThemes(this.server);
await click('[data-test-modal-close-button]');
expect(find('[data-test-theme-warnings-modal]')).to.not.exist;
// theme activation shows warnings
this.server.put('themes/:theme/activate', function ({themes}, {params}) {
themes.all().update('active', false);
let theme = themes.findBy({name: params.theme}).update({active: true});
theme.update({warnings: [{
level: 'warning',
rule: 'Assets such as CSS & JS must use the <code>{{asset}}</code> helper',
details: '<p>The listed files should be included using the <code>{{asset}}</code> helper. For more information, please see the <a href="https://ghost.org/docs/themes/helpers/asset/">asset helper documentation</a>.</p>',
failures: [
{
ref: '/assets/dist/img/apple-touch-icon.png'
},
{
ref: '/assets/dist/img/favicon.ico'
},
{
ref: '/assets/dist/css/blackpalm.min.css'
},
{
ref: '/assets/dist/js/blackpalm.min.js'
}
],
code: 'GS030-ASSET-REQ'
}]});
return {themes: [theme]};
});
await click('[data-test-theme-id="test-2"] [data-test-theme-activate-button]');
expect(find('[data-test-theme-warnings-modal]')).to.exist;
expect(
find('[data-test-theme-warnings-title]').textContent.trim(),
'modal title after activating theme with warnings'
).to.equal('Activation successful with warnings');
await click('[data-test-toggle-details]');
expect(
find('.theme-validation-details').textContent,
'top-level warnings are displayed in activation warnings'
).to.match(/The listed files should be included using the {{asset}} helper/);
expect(
find('.theme-validation-list ul li').textContent,
'individual warning failures are displayed in activation warnings'
).to.match(/\/assets\/dist\/img\/apple-touch-icon\.png/);
// restore default mirage handlers
mockThemes(this.server);
await click('[data-test-modal-close-button]');
// reactivate casper to continue tests
await click('[data-test-theme-id="casper"] [data-test-theme-activate-button]');
// theme deletion displays modal
await click('[data-test-theme-id="test-1"] [data-test-theme-delete-button]');
expect(
findAll('[data-test-delete-theme-modal]').length,
'theme deletion modal displayed after button click'
).to.equal(1);
// cancelling theme deletion closes modal
await click('.fullscreen-modal [data-test-cancel-button]');
expect(
findAll('.fullscreen-modal').length === 0,
'delete theme modal is closed when cancelling'
).to.be.true;
// confirming theme deletion closes modal and refreshes list
await click('[data-test-theme-id="test-1"] [data-test-theme-delete-button]');
await click('.fullscreen-modal [data-test-delete-button]');
expect(
findAll('.fullscreen-modal').length === 0,
'delete theme modal closes after deletion'
).to.be.true;
expect(
findAll('[data-test-theme-id]').length,
'number of themes in list shrinks after delete'
).to.equal(5);
expect(
find('[data-test-theme-title]').textContent,
'correct theme is removed from theme list after deletion'
).to.not.match(/Test 1/);
// validation errors are handled when deleting a theme
this.server.del('/themes/:theme/', function () {
return new Mirage.Response(422, {}, {
errors: [{
message: 'Can\'t delete theme'
}]
});
});
await click('[data-test-theme-id="test-2"] [data-test-theme-delete-button]');
await click('.fullscreen-modal [data-test-delete-button]');
expect(
findAll('.fullscreen-modal').length === 0,
'delete theme modal closes after failed deletion'
).to.be.true;
expect(
findAll('.gh-alert').length,
'alert is shown when deletion fails'
).to.equal(1);
expect(
find('.gh-alert').textContent,
'failed deletion alert has correct text'
).to.match(/Can't delete theme/);
// restore default mirage handlers
mockThemes(this.server);
});
it('can delete then re-upload the same theme', async function () {
this.server.loadFixtures('themes');
// mock theme upload to emulate uploading theme with same id
this.server.post('/themes/upload/', function ({themes}) {
let theme = themes.create({
name: 'foo',
package: {
name: 'Foo',
version: '0.1'
}
});
return {themes: [theme]};
});
await visit('/settings/theme');
await click('[data-test-theme-id="foo"] [data-test-theme-delete-button]');
await click('.fullscreen-modal [data-test-delete-button]');
await click('[data-test-button="uploadtheme"]');
await fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'foo.zip', type: 'application/zip'});
// this will fail if upload failed because there won't be an activate now button
await click('.fullscreen-modal [data-test-activate-now-button]');
});
});
});