theme management UI

refs https://github.com/TryGhost/Ghost/issues/7204, requires https://github.com/TryGhost/Ghost/pull/7209

- replaces theme dropdown with a table
- adds theme upload modal
    - validates theme mime type
    - prevents upload of `casper.zip` (default Casper theme can't be overwritten)
    - warns if an upload will overwrite an existing theme
    - gives option of immediately activating the uploaded theme or closing after successful upload
- adds theme activation link/action
- adds theme download link/action
- adds theme deletion modal
    - warns about no undo possibility
    - offers possibility to download theme
- modifies mirage config to handle theme changes
This commit is contained in:
Kevin Ansfield 2016-08-17 16:01:46 +01:00
parent 3bfc342314
commit 0abe447551
25 changed files with 983 additions and 104 deletions

View File

@ -32,6 +32,7 @@ export default Component.extend({
uploadPercentage: 0,
ajax: injectService(),
eventBus: injectService(),
notifications: injectService(),
formData: computed('file', function () {
@ -57,6 +58,32 @@ export default Component.extend({
return htmlSafe(`width: ${width}`);
}),
// we can optionally listen to a named event bus channel so that the upload
// process can be triggered externally
init() {
this._super(...arguments);
let listenTo = this.get('listenTo');
if (listenTo) {
this.get('eventBus').subscribe(`${listenTo}:upload`, this, function (file) {
if (file) {
this.set('file', file);
}
this.send('upload');
});
}
},
willDestroyElement() {
let listenTo = this.get('listenTo');
this._super(...arguments);
if (listenTo) {
this.get('eventBus').unsubscribe(`${listenTo}:upload`);
}
},
dragOver(event) {
if (!event.dataTransfer) {
return;
@ -142,7 +169,7 @@ export default Component.extend({
} else if (isRequestEntityTooLargeError(error)) {
message = 'The file you uploaded was larger than the maximum file size your server allows.';
} else if (error.errors && !isBlank(error.errors[0].message)) {
message = error.errors[0].message;
message = htmlSafe(error.errors[0].message);
} else {
message = 'Something went wrong :(';
}
@ -190,6 +217,12 @@ export default Component.extend({
}
},
upload() {
if (this.get('file')) {
this.generateRequest();
}
},
reset() {
this.set('file', null);
this.set('uploadPercentage', 0);

View File

@ -0,0 +1,24 @@
import Component from 'ember-component';
import computed from 'ember-computed';
export default Component.extend({
availableThemes: null,
activeTheme: null,
themes: computed('availableThemes', 'activeTheme', function () {
return this.get('availableThemes').map((t) => {
let theme = {};
theme.name = t.name;
theme.label = t.package ? `${t.package.name} - ${t.package.version}` : t.name;
theme.package = t.package;
theme.active = !!t.active;
theme.isDefault = t.name === 'casper';
theme.isDeletable = !theme.active && !theme.isDefault;
return theme;
}).sortBy('label');
}).readOnly()
});

View File

@ -0,0 +1,21 @@
import ModalComponent from 'ghost-admin/components/modals/base';
import {alias} from 'ember-computed';
import {invokeAction} from 'ember-invoke-action';
export default ModalComponent.extend({
submitting: false,
theme: alias('model.theme'),
download: alias('model.download'),
actions: {
confirm() {
this.set('submitting', true);
invokeAction(this, 'confirm').finally(() => {
this.send('closeModal');
});
}
}
});

View File

@ -0,0 +1,109 @@
import ModalComponent from 'ghost-admin/components/modals/base';
import computed, {mapBy, or} from 'ember-computed';
import {invokeAction} from 'ember-invoke-action';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {UnsupportedMediaTypeError} from 'ghost-admin/services/ajax';
import {isBlank} from 'ember-utils';
import run from 'ember-runloop';
import injectService from 'ember-service/inject';
export default ModalComponent.extend({
accept: 'application/zip',
availableThemes: null,
closeDisabled: false,
file: null,
theme: false,
displayOverwriteWarning: false,
eventBus: injectService(),
hideUploader: or('theme', 'displayOverwriteWarning'),
uploadUrl: computed(function () {
return `${ghostPaths().apiRoot}/themes/upload/`;
}),
themeName: computed('theme.{name,package.name}', function () {
let t = this.get('theme');
return t.package ? `${t.package.name} - ${t.package.version}` : t.name;
}),
availableThemeNames: mapBy('model.availableThemes', 'name'),
fileThemeName: computed('file', function () {
let file = this.get('file');
return file.name.replace(/\.zip$/, '');
}),
canActivateTheme: computed('theme', function () {
let theme = this.get('theme');
return theme && !theme.active;
}),
actions: {
validateTheme(file) {
let accept = this.get('accept');
let themeName = file.name.replace(/\.zip$/, '');
let availableThemeNames = this.get('availableThemeNames');
this.set('file', file);
if (!isBlank(accept) && file && accept.indexOf(file.type) === -1) {
return new UnsupportedMediaTypeError();
}
if (file.name.match(/^casper\.zip$/i)) {
return {errors: [{message: 'Sorry, the default Casper theme cannot be overwritten.<br>Please rename your zip file and try again.'}]};
}
if (!this._allowOverwrite && availableThemeNames.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.get('eventBus').publish('themeUploader:upload', this.get('file'));
});
},
uploadStarted() {
this.set('closeDisabled', true);
},
uploadFinished() {
this.set('closeDisabled', false);
},
uploadSuccess(response) {
this.set('theme', response.themes[0]);
// invoke the passed in confirm action
invokeAction(this, 'model.uploadSuccess', this.get('theme'));
},
confirm() {
// noop - we don't want the enter key doing anything
},
activate() {
invokeAction(this, 'model.activate', this.get('theme'));
invokeAction(this, 'closeModal');
},
closeModal() {
if (!this.get('closeDisabled')) {
this._super(...arguments);
}
}
}
});

View File

@ -1,37 +1,29 @@
import Controller from 'ember-controller';
import computed from 'ember-computed';
import computed, {notEmpty} from 'ember-computed';
import injectService from 'ember-service/inject';
import observer from 'ember-metal/observer';
import run from 'ember-runloop';
import SettingsSaveMixin from 'ghost-admin/mixins/settings-save';
import randomPassword from 'ghost-admin/utils/random-password';
import $ from 'jquery';
export default Controller.extend(SettingsSaveMixin, {
availableTimezones: null,
themeToDelete: null,
showUploadLogoModal: false,
showUploadCoverModal: false,
showDeleteThemeModal: notEmpty('themeToDelete'),
availableTimezones: null,
notifications: injectService(),
ajax: injectService(),
config: injectService(),
ghostPaths: injectService(),
notifications: injectService(),
session: injectService(),
_scratchFacebook: null,
_scratchTwitter: null,
selectedTheme: computed('model.activeTheme', 'themes', function () {
let activeTheme = this.get('model.activeTheme');
let themes = this.get('themes');
let selectedTheme;
themes.forEach((theme) => {
if (theme.name === activeTheme) {
selectedTheme = theme;
}
});
return selectedTheme;
}),
logoImageSource: computed('model.logo', function () {
return this.get('model.logo') || '';
}),
@ -55,21 +47,6 @@ export default Controller.extend(SettingsSaveMixin, {
}
}),
themes: computed(function () {
return this.get('model.availableThemes').reduce(function (themes, t) {
let theme = {};
theme.name = t.name;
theme.label = t.package ? `${t.package.name} - ${t.package.version}` : t.name;
theme.package = t.package;
theme.active = !!t.active;
themes.push(theme);
return themes;
}, []);
}).readOnly(),
generatePassword: observer('model.isPrivate', function () {
this.get('model.errors').remove('password');
if (this.get('model.isPrivate') && this.get('model.hasDirtyAttributes')) {
@ -77,6 +54,21 @@ export default Controller.extend(SettingsSaveMixin, {
}
}),
_deleteTheme() {
let theme = this.get('themeToDelete');
let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}/`;
if (!theme) {
return;
}
return this.get('ajax').del(themeURL).then(() => {
this.send('reloadSettings');
}).catch((error) => {
this.get('notifications').showAPIError(error);
});
},
save() {
let notifications = this.get('notifications');
let config = this.get('config');
@ -107,10 +99,38 @@ export default Controller.extend(SettingsSaveMixin, {
setTheme(theme) {
this.set('model.activeTheme', theme.name);
this.send('save');
},
downloadTheme(theme) {
let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}`;
let accessToken = this.get('session.data.authenticated.access_token');
let downloadURL = `${themeURL}/download/?access_token=${accessToken}`;
let iframe = $('#iframeDownload');
if (iframe.length === 0) {
iframe = $('<iframe>', {id: 'iframeDownload'}).hide().appendTo('body');
}
iframe.attr('src', downloadURL);
},
deleteTheme(theme) {
if (theme) {
return this.set('themeToDelete', theme);
}
return this._deleteTheme();
},
hideDeleteThemeModal() {
this.set('themeToDelete', null);
},
setTimezone(timezone) {
this.set('model.activeTimezone', timezone.name);
},
toggleUploadCoverModal() {
this.toggleProperty('showUploadCoverModal');
},

View File

@ -5,6 +5,7 @@ import mockSettings from './config/settings';
import mockSlugs from './config/slugs';
import mockSubscribers from './config/subscribers';
import mockTags from './config/tags';
import mockThemes from './config/themes';
import mockUsers from './config/users';
// import {versionMismatchResponse} from 'utils';
@ -17,6 +18,9 @@ export default function () {
// Mock endpoints here to override real API requests during development
// this.put('/posts/:id/', versionMismatchResponse);
// mockSubscribers(this);
this.loadFixtures('settings');
mockSettings(this);
mockThemes(this);
// keep this line, it allows all other API requests to hit the real server
this.passthrough();
@ -40,6 +44,7 @@ export function testConfig() {
mockSlugs(this);
mockSubscribers(this);
mockTags(this);
mockThemes(this);
mockUsers(this);
/* Notifications -------------------------------------------------------- */

View File

@ -18,11 +18,28 @@ export default function mockSettings(server) {
});
server.put('/settings/', function (db, request) {
console.log('/settings/', request.requestBody);
let newSettings = JSON.parse(request.requestBody).settings;
db.settings.remove();
db.settings.insert(newSettings);
let [activeTheme] = db.settings.where({key: 'activeTheme'});
let [availableThemes] = db.settings.where({key: 'availableThemes'});
console.log('activeTheme', activeTheme);
console.log('availableThemes', availableThemes);
availableThemes.value.forEach((theme) => {
if (theme.name === activeTheme.value) {
theme.active = true;
} else {
theme.active = false;
}
});
db.settings.update(availableThemes.id, availableThemes);
return {
meta: {},
settings: db.settings

View File

@ -0,0 +1,27 @@
let themeCount = 1;
export default function mockThemes(server) {
server.post('/themes/upload/', function (db/*, request*/) {
let [availableThemes] = db.settings.where({key: 'availableThemes'});
// pretender/mirage doesn't currently process FormData so we can't use
// any info passed in through the request
let theme = {
name: `test-${themeCount}`,
package: {
name: `Test ${themeCount}`,
version: '0.1'
},
active: false
};
themeCount++;
availableThemes.value.pushObject(theme);
db.settings.remove({key: 'availableThemes'});
db.settings.insert(availableThemes);
return {
themes: [theme]
};
});
}

View File

@ -214,6 +214,7 @@ export default [
},
{
key: 'availableThemes',
id: 18,
value: [
{
name: 'casper',
@ -222,6 +223,16 @@ export default [
version: '1.0'
},
active: true
},
{
name: 'foo',
package: {
name: 'Foo',
version: '0.1'
}
},
{
name: 'bar'
}
],
type: 'theme'

View File

@ -44,7 +44,9 @@ GhostRouter.map(function () {
this.route('user', {path: ':user_slug'});
});
this.route('settings.general', {path: '/settings/general'});
this.route('settings.general', {path: '/settings/general'}, function () {
this.route('uploadtheme');
});
this.route('settings.tags', {path: '/settings/tags'}, function () {
this.route('tag', {path: ':tag_slug'});
this.route('new');

View File

@ -11,6 +11,11 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
config: injectService(),
// TODO: replace with a synchronous settings service
querySettings() {
return this.store.queryRecord('setting', {type: 'blog,theme,private'});
},
beforeModel() {
this._super(...arguments);
return this.get('session.user')
@ -20,7 +25,7 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
model() {
return RSVP.hash({
settings: this.store.queryRecord('setting', {type: 'blog,theme,private'}),
settings: this.querySettings(),
availableTimezones: this.get('config.availableTimezones')
});
},
@ -32,7 +37,17 @@ export default AuthenticatedRoute.extend(styleBody, CurrentUserSettings, {
actions: {
save() {
this.get('controller').send('save');
return this.get('controller').send('save');
},
reloadSettings() {
return this.querySettings((settings) => {
this.set('controller.model', settings);
});
},
activateTheme(theme) {
return this.get('controller').send('setTheme', theme);
}
}
});

View File

@ -0,0 +1,14 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
export default AuthenticatedRoute.extend({
model() {
return this.modelFor('settings.general').settings.get('availableThemes');
},
actions: {
cancel() {
this.transitionTo('settings.general');
}
}
});

View File

@ -0,0 +1,14 @@
import Service from 'ember-service';
import Evented from 'ember-evented';
export default Service.extend(Evented, {
publish() {
return this.trigger(...arguments);
},
subscribe() {
return this.on(...arguments);
},
unsubscribe() {
return this.off(...arguments);
}
});

View File

@ -10,7 +10,6 @@
margin: 1.6em 0;
min-height: 130px;
width: 100%;
height: 130px;
background: #f6f7f8;
border-radius: 4px;
color: #808284;

View File

@ -161,3 +161,93 @@
margin-top: 5px;
}
}
/* Themes
/* ---------------------------------------------------------- */
.settings-themes h3 {
margin-bottom: 1.6em;
font-size: 16px;
}
.theme-list-item {
display: flex;
justify-content: start;
align-items: center;
padding: 13px 15px;
border-top: 1px solid #dfe1e3;
}
.theme-list-item--active {
background: color(#dfe1e3 lightness(+10%));
}
.theme-list-item-body .name {
display: inline-block;
color: var(--darkgrey);
white-space: nowrap;
font-weight: 400;
user-select: all;
}
.theme-list-item:last-of-type {
margin-bottom: 1em;
border-bottom: 1px solid #dfe1e3;
}
.theme-list-item-body {
flex: 1 1 auto;
align-items: stretch;
line-height: 1;
}
.theme-list-action:last-child {
margin-right: 0;
}
.theme-list-action {
float: left;
margin-right: 20px;
text-transform: uppercase;
font-size: 11px;
}
a.theme-list-action {
text-decoration: underline;
}
/* account for length difference between Active and Activate */
.theme-list-action-activate {
min-width: 52px;
}
.theme-list-item--active .theme-list-action-activate {
color: var(--green);
}
@media (max-width: 550px) {
.theme-list-item {
flex-direction: column;
align-items: flex-start;
height: auto;
}
.theme-list-item-body .name {
font-size: 15px;
}
.theme-list-item-aside {
display: flex;
flex-direction: row-reverse;
}
.theme-list-item-body {
margin-bottom: 0.35em;
width: 100%;
}
.theme-list-action:last-child {
margin-right: 20px;
}
}

View File

@ -0,0 +1,32 @@
{{#if themes}}
<div class="theme-list">
{{#each themes as |theme|}}
<div class="theme-list-item {{if theme.active "theme-list-item--active"}}">
<div class="theme-list-item-body">
<span class="name">{{theme.label}} {{#if theme.isDefault}}(default){{/if}}</span>
</div>
<div class="theme-list-item-aside">
{{#if theme.isDeletable}}
<a href="#" {{action deleteTheme theme}} disabled={{theme.active}} class="theme-list-action">
Delete
</a>
{{/if}}
<a href="#" {{action downloadTheme theme}} class="theme-list-action">
Download
</a>
{{#if theme.active}}
<span class="theme-list-action theme-list-action-activate">Active</span>
{{else}}
<a href="#" {{action activateTheme theme}} class="theme-list-action theme-list-action-activate">
Activate
</a>
{{/if}}
</div>
</div>
{{/each}}
</div>
{{else}}
No theme found!
{{/if}}

View File

@ -0,0 +1,20 @@
<header class="modal-header">
<h1>Are you sure you want to delete this theme?</h1>
</header>
<a class="close icon-x" href="" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
<strong>WARNING:</strong>
You're about to delete "<strong>{{theme.label}}</strong>".
This is permanent!
No backups, no restores, no magic undo button. We warned you, ok?
<br>
<br>
<strong>RECOMMENDED:</strong>
<a href="#" {{action download}}>Download your theme before continuing</a>
</div>
<div class="modal-footer">
<button {{action "closeModal"}} class="btn btn-default btn-minor">Cancel</button>
{{#gh-spin-button action="confirm" class="btn btn-red" submitting=submitting}}Delete{{/gh-spin-button}}
</div>

View File

@ -0,0 +1,50 @@
<header class="modal-header">
<h1>
{{#if theme}}
Upload successful!
{{else}}
Upload a theme
{{/if}}
</h1>
</header>
<a class="close icon-x" href="#" title="Close" {{action "closeModal"}}><span class="hidden">Close</span></a>
<div class="modal-body">
{{#if theme}}
<p>
"{{themeName}}" uploaded successfully.
{{#if canActivateTheme}}Do you want to activate it now?{{/if}}
</p>
{{else if displayOverwriteWarning}}
<p>
"{{fileThemeName}}" will overwrite an existing theme of the same name. Are you sure?
</p>
{{else}}
{{gh-file-uploader
url=uploadUrl
paramName="theme"
accept=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")
listenTo="themeUploader"}}
{{/if}}
</div>
<div class="modal-footer">
<button {{action "closeModal"}} disabled={{closeDisabled}} class="btn btn-default btn-minor">
{{#if theme}}Close{{else}}Cancel{{/if}}
</button>
{{#if displayOverwriteWarning}}
<button {{action "confirmOverwrite"}} class="btn btn-red">
Overwrite
</button>
{{/if}}
{{#if canActivateTheme}}
<button {{action "activate"}} class="btn btn-green">
Activate Now
</button>
{{/if}}
</div>

View File

@ -39,9 +39,9 @@
{{#if showUploadLogoModal}}
{{gh-fullscreen-modal "upload-image"
model=(hash model=model imageProperty="logo")
close=(action "toggleUploadLogoModal")
modifier="action wide"}}
model=(hash model=model imageProperty="logo")
close=(action "toggleUploadLogoModal")
modifier="action wide"}}
{{/if}}
</div>
@ -56,9 +56,9 @@
{{#if showUploadCoverModal}}
{{gh-fullscreen-modal "upload-image"
model=(hash model=model imageProperty="cover")
close=(action "toggleUploadCoverModal")
modifier="action wide"}}
model=(hash model=model imageProperty="cover")
close=(action "toggleUploadCoverModal")
modifier="action wide"}}
{{/if}}
</div>
@ -80,22 +80,6 @@
</label>
</div>
<div class="form-group for-select">
<label for="activeTheme">Theme</label>
<span class="gh-select" data-select-text="{{selectedTheme.label}}" tabindex="0">
{{gh-select-native
id="activeTheme"
name="general[activeTheme]"
content=themes
optionValuePath="name"
optionLabelPath="label"
selection=selectedTheme
action="setTheme"
}}
</span>
<p>Select a theme for your blog</p>
</div>
<div class="form-group">
{{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="facebook"}}
<label for="facebook">Facebook Page</label>
@ -136,6 +120,35 @@
{{/if}}
</fieldset>
<div class="settings-themes">
<h3>Themes</h3>
{{gh-theme-table
availableThemes=model.availableThemes
activeTheme=model.activeTheme
activateTheme=(action "setTheme")
downloadTheme=(action "downloadTheme")
deleteTheme=(action "deleteTheme")}}
<div class="form-group">
{{#link-to "settings.general.uploadtheme" class="btn btn-green"}}
Upload a theme
{{/link-to}}
</div>
{{#if showDeleteThemeModal}}
{{gh-fullscreen-modal "delete-theme"
model=(hash
theme=themeToDelete
download=(action "downloadTheme" themeToDelete)
)
close=(action "hideDeleteThemeModal")
confirm=(action "deleteTheme")
modifier="action wide"}}
{{/if}}
</div>
</form>
</section>
</section>
{{outlet}}

View File

@ -0,0 +1,8 @@
{{gh-fullscreen-modal "upload-theme"
model=(hash
availableThemes=model
uploadSuccess=(route-action 'reloadSettings')
activate=(route-action 'activateTheme')
)
close=(route-action "cancel")
modifier="action wide"}}

View File

@ -11,6 +11,8 @@ import run from 'ember-runloop';
import startApp from '../../helpers/start-app';
import destroyApp from '../../helpers/destroy-app';
import { invalidateSession, authenticateSession } from 'ghost-admin/tests/helpers/ember-simple-auth';
import Mirage from 'ember-cli-mirage';
import mockThemes from 'ghost-admin/mirage/config/themes';
describe('Acceptance: Settings - General', function () {
let application;
@ -125,12 +127,6 @@ describe('Acceptance: Settings - General', function () {
andThen(() => {
expect(find('.fullscreen-modal').length).to.equal(0);
});
// renders theme selector correctly
andThen(() => {
expect(find('#activeTheme select option').length, 'available themes').to.equal(1);
expect(find('#activeTheme select option').text().trim()).to.equal('Blog - 1.0');
});
});
it('renders timezone selector correctly', function () {
@ -343,5 +339,161 @@ describe('Acceptance: Settings - General', function () {
});
});
it('allows management of themes', 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
visit('/settings/general');
// lists available themes (themes are specified in mirage/fixtures/settings)
andThen(() => {
expect(
find('.theme-list-item').length,
'shows correct number of themes'
).to.equal(3);
expect(
find('.theme-list-item:contains("Blog")').hasClass('theme-list-item--active'),
'Blog theme marked as active'
);
});
// theme upload displays modal
click('a:contains("Upload a theme")');
andThen(() => {
expect(
find('.fullscreen-modal .modal-content:contains("Upload a theme")').length,
'theme upload modal displayed after button click'
).to.equal(1);
});
// cancelling theme upload closes modal
click('.fullscreen-modal button:contains("Cancel")');
andThen(() => {
expect(
find('.fullscreen-modal').length === 0,
'modal is closed when cancelling'
).to.be.true;
});
// theme upload validates mime type
click('a:contains("Upload a theme")');
fileUpload('.fullscreen-modal input[type="file"]', ['test'], {type: 'text/csv'});
andThen(() => {
expect(
find('.fullscreen-modal .failed').text(),
'validation error is shown for invalid mime type'
).to.match(/is not supported/);
});
// theme upload validates casper.zip
click('button:contains("Try Again")');
fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'casper.zip', type: 'application/zip'});
andThen(() => {
expect(
find('.fullscreen-modal .failed').text(),
'validation error is shown when uploading casper.zip'
).to.match(/default Casper theme cannot be overwritten/);
});
// theme upload handles validation errors
andThen(() => {
server.post('/themes/upload/', function () {
return new Mirage.Response(422, {}, {
errors: [{
message: 'Invalid theme'
}]
});
});
});
click('button:contains("Try Again")');
fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'error.zip', type: 'application/zip'});
andThen(() => {
expect(
find('.fullscreen-modal .failed').text().trim(),
'validation error is passed through from server'
).to.equal('Invalid theme');
// reset to default mirage handlers
mockThemes(server);
});
// theme upload handles success then close
click('button:contains("Try Again")');
fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'theme-1.zip', type: 'application/zip'});
andThen(() => {
expect(
find('.fullscreen-modal h1').text().trim(),
'modal header after successful upload'
).to.equal('Upload successful!');
expect(
find('.modal-body').text(),
'modal displays theme name after successful upload'
).to.match(/"Test 1 - 0\.1" uploaded successfully/);
expect(
find('.theme-list-item').length,
'number of themes in list grows after upload'
).to.equal(4);
expect(
find('.theme-list-item:contains("Test 1 - 0.1")').hasClass('theme-list-item--active'),
'newly uploaded theme is active'
).to.be.false;
});
click('.fullscreen-modal button:contains("Close")');
// theme upload handles success then activate
click('a:contains("Upload a theme")');
fileUpload('.fullscreen-modal input[type="file"]', ['test'], {name: 'theme-2.zip', type: 'application/zip'});
click('button:contains("Activate Now")');
andThen(() => {
expect(
find('.theme-list-item').length,
'number of themes in list grows after upload and activate'
).to.equal(5);
expect(
find('.theme-list-item:contains("Test 2 - 0.1")').hasClass('theme-list-item--active'),
'newly uploaded+activated theme is active'
).to.be.true;
});
// theme activation switches active theme
click('.theme-list-item:contains("Blog") a:contains("Activate")');
andThen(() => {
expect(
find('.theme-list-item:contains("Test 2 - 0.1")').hasClass('theme-list-item--active'),
'previously active theme is not active'
).to.be.false;
expect(
find('.theme-list-item:contains("Blog")').hasClass('theme-list-item--active'),
'activated theme is active'
).to.be.true;
});
// theme deletion displays modal
// cancelling theme deletion closes modal
// confirming theme deletion closes modal and refreshes list
});
});
});

View File

@ -0,0 +1,174 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
import $ from 'jquery';
import sinon from 'sinon';
import run from 'ember-runloop';
describeComponent(
'gh-theme-table',
'Integration: Component: gh-theme-table',
{
integration: true
},
function() {
it('renders', function() {
this.set('availableThemes', [
{name: 'Daring', package: {name: 'Daring', version: '0.1.4'}, active: true},
{name: 'casper', package: {name: 'Casper', version: '1.3.1'}},
{name: 'oscar-ghost-1.1.0', package: {name: 'Lanyon', version: '1.1.0'}},
{name: 'foo'}
]);
this.set('activeTheme', 'Daring');
this.set('actionHandler', sinon.spy());
this.render(hbs`{{gh-theme-table
availableThemes=availableThemes
activeTheme=activeTheme
activateTheme=(action actionHandler)
downloadTheme=(action actionHandler)
deleteTheme=(action actionHandler)
}}`);
expect(this.$('.theme-list').length, '.theme-list is present').to.equal(1);
expect(this.$('.theme-list-item').length, 'number of rows').to.equal(4);
let packageNames = this.$('.theme-list-item-body .name').map((i, name) => {
return $(name).text().trim();
}).toArray();
expect(
packageNames,
'themes are ordered by label, casper has "default", package versions are shown'
).to.deep.equal([
'Casper - 1.3.1 (default)',
'Daring - 0.1.4',
'foo',
'Lanyon - 1.1.0'
]);
expect(
this.$('.theme-list-item:contains("Daring")').hasClass('theme-list-item--active'),
'active theme is highlighted'
).to.be.true;
expect(
this.$('.theme-list-item:not(:contains("Daring"))').find('a:contains("Activate")').length === 3,
'non-active themes have an activate link'
).to.be.true;
expect(
this.$('.theme-list-item:contains("Daring")').find('a:contains("Activate")').length === 0,
'active theme doesn\'t have an activate link'
).to.be.true;
expect(
this.$('a:contains("Download")').length,
'all themes have a download link'
).to.equal(4);
expect(
this.$('.theme-list-item:contains("foo")').find('a:contains("Delete")').length === 1,
'non-active, non-casper theme has delete link'
).to.be.true;
expect(
this.$('.theme-list-item:contains("Casper")').find('a:contains("Delete")').length === 0,
'casper doesn\'t have delete link'
).to.be.true;
expect(
this.$('.theme-list-item--active').find('a:contains("Delete")').length === 0,
'active theme doesn\'t have delete link'
).to.be.true;
});
it('delete link triggers passed in action', function () {
let deleteAction = sinon.spy();
let actionHandler = sinon.spy();
this.set('availableThemes', [
{name: 'Foo', active: true},
{name: 'Bar'}
]);
this.set('activeTheme', 'Foo');
this.set('deleteAction', deleteAction);
this.set('actionHandler', actionHandler);
this.render(hbs`{{gh-theme-table
availableThemes=availableThemes
activeTheme=activeTheme
activateTheme=(action actionHandler)
downloadTheme=(action actionHandler)
deleteTheme=(action deleteAction)
}}`);
run(() => {
this.$('.theme-list-item:contains("Bar") a:contains("Delete")').click();
});
expect(deleteAction.calledOnce).to.be.true;
expect(deleteAction.firstCall.args[0].name).to.equal('Bar');
});
it('download link triggers passed in action', function () {
let downloadAction = sinon.spy();
let actionHandler = sinon.spy();
this.set('availableThemes', [
{name: 'Foo', active: true},
{name: 'Bar'}
]);
this.set('activeTheme', 'Foo');
this.set('downloadAction', downloadAction);
this.set('actionHandler', actionHandler);
this.render(hbs`{{gh-theme-table
availableThemes=availableThemes
activeTheme=activeTheme
activateTheme=(action actionHandler)
downloadTheme=(action downloadAction)
deleteTheme=(action actionHandler)
}}`);
run(() => {
this.$('.theme-list-item:contains("Foo") a:contains("Download")').click();
});
expect(downloadAction.calledOnce).to.be.true;
expect(downloadAction.firstCall.args[0].name).to.equal('Foo');
});
it('activate link triggers passed in action', function () {
let activateAction = sinon.spy();
let actionHandler = sinon.spy();
this.set('availableThemes', [
{name: 'Foo', active: true},
{name: 'Bar'}
]);
this.set('activeTheme', 'Foo');
this.set('activateAction', activateAction);
this.set('actionHandler', actionHandler);
this.render(hbs`{{gh-theme-table
availableThemes=availableThemes
activeTheme=activeTheme
activateTheme=(action activateAction)
downloadTheme=(action actionHandler)
deleteTheme=(action actionHandler)
}}`);
run(() => {
this.$('.theme-list-item:contains("Bar") a:contains("Activate")').click();
});
expect(activateAction.calledOnce).to.be.true;
expect(activateAction.firstCall.args[0].name).to.equal('Bar');
});
}
);

View File

@ -0,0 +1,30 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeComponent,
it
} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describeComponent(
'modals/upload-theme',
'Integration: Component: modals/upload-theme',
{
integration: true
},
function() {
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#modals/upload-theme}}
// template content
// {{/modals/upload-theme}}
// `);
this.render(hbs`{{modals/upload-theme}}`);
expect(this.$()).to.have.length(1);
});
}
);

View File

@ -54,43 +54,5 @@ describeModule(
expect(controller.get('model.permalinks')).to.equal('/:year/:month/:day/:slug/');
});
});
it('themes should be correct', function () {
let themes = [];
let controller;
themes.push({
name: 'casper',
active: true,
package: {
name: 'Casper',
version: '1.1.5'
}
});
themes.push({
name: 'rasper',
package: {
name: 'Rasper',
version: '1.0.0'
}
});
controller = this.subject({
model: EmberObject.create({
availableThemes: themes
})
});
themes = controller.get('themes');
expect(themes).to.be.an.Array;
expect(themes.length).to.equal(2);
expect(themes.objectAt(0).name).to.equal('casper');
expect(themes.objectAt(0).active).to.be.ok;
expect(themes.objectAt(0).label).to.equal('Casper - 1.1.5');
expect(themes.objectAt(1).name).to.equal('rasper');
expect(themes.objectAt(1).active).to.not.be.ok;
expect(themes.objectAt(1).label).to.equal('Rasper - 1.0.0');
});
}
);

View File

@ -0,0 +1,37 @@
/* jshint expr:true */
import { expect } from 'chai';
import {
describeModule,
it
} from 'ember-mocha';
import sinon from 'sinon';
describeModule(
'service:event-bus',
'Unit: Service: event-bus',
{},
function() {
it('works', function () {
let service = this.subject();
let eventHandler = sinon.spy();
service.subscribe('test-event', eventHandler);
service.publish('test-event', 'test');
service.unsubscribe('test-event', eventHandler);
service.publish('test-event', 'test two');
expect(
eventHandler.calledOnce,
'event handler only triggered once'
).to.be.true;
expect(
eventHandler.calledWith('test'),
'event handler was passed correct arguments'
).to.be.true;
});
}
);