+
WARNING:
+ You're about to delete "
{{theme.label}}".
+ This is permanent!
+ No backups, no restores, no magic undo button. We warned you, ok?
+
+
+
RECOMMENDED:
+
Download your theme before continuing
+
+
+
diff --git a/ghost/admin/app/templates/components/modals/upload-theme.hbs b/ghost/admin/app/templates/components/modals/upload-theme.hbs
new file mode 100644
index 0000000000..330bd6eb28
--- /dev/null
+++ b/ghost/admin/app/templates/components/modals/upload-theme.hbs
@@ -0,0 +1,50 @@
+
+
+ {{#if theme}}
+
+ "{{themeName}}" uploaded successfully.
+ {{#if canActivateTheme}}Do you want to activate it now?{{/if}}
+
+ {{else if displayOverwriteWarning}}
+
+ "{{fileThemeName}}" will overwrite an existing theme of the same name. Are you sure?
+
+ {{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}}
+
+
+
diff --git a/ghost/admin/app/templates/settings/general.hbs b/ghost/admin/app/templates/settings/general.hbs
index ff7c921965..d08c3c0db2 100644
--- a/ghost/admin/app/templates/settings/general.hbs
+++ b/ghost/admin/app/templates/settings/general.hbs
@@ -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}}
@@ -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}}
@@ -80,22 +80,6 @@
-
{{#gh-form-group errors=model.errors hasValidated=model.hasValidated property="facebook"}}
@@ -136,6 +120,35 @@
{{/if}}
+
+
Themes
+
+ {{gh-theme-table
+ availableThemes=model.availableThemes
+ activeTheme=model.activeTheme
+ activateTheme=(action "setTheme")
+ downloadTheme=(action "downloadTheme")
+ deleteTheme=(action "deleteTheme")}}
+
+
+ {{#link-to "settings.general.uploadtheme" class="btn btn-green"}}
+ Upload a theme
+ {{/link-to}}
+
+
+ {{#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}}
+
+
+{{outlet}}
diff --git a/ghost/admin/app/templates/settings/general/uploadtheme.hbs b/ghost/admin/app/templates/settings/general/uploadtheme.hbs
new file mode 100644
index 0000000000..04dc0d779f
--- /dev/null
+++ b/ghost/admin/app/templates/settings/general/uploadtheme.hbs
@@ -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"}}
diff --git a/ghost/admin/tests/acceptance/settings/general-test.js b/ghost/admin/tests/acceptance/settings/general-test.js
index 49c29a1e5c..acc9e8bafb 100644
--- a/ghost/admin/tests/acceptance/settings/general-test.js
+++ b/ghost/admin/tests/acceptance/settings/general-test.js
@@ -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
+ });
});
});
diff --git a/ghost/admin/tests/integration/components/gh-theme-table-test.js b/ghost/admin/tests/integration/components/gh-theme-table-test.js
new file mode 100644
index 0000000000..c8bc872091
--- /dev/null
+++ b/ghost/admin/tests/integration/components/gh-theme-table-test.js
@@ -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');
+ });
+ }
+);
diff --git a/ghost/admin/tests/integration/components/modals/upload-theme-test.js b/ghost/admin/tests/integration/components/modals/upload-theme-test.js
new file mode 100644
index 0000000000..fe801fbb2d
--- /dev/null
+++ b/ghost/admin/tests/integration/components/modals/upload-theme-test.js
@@ -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);
+ });
+ }
+);
diff --git a/ghost/admin/tests/unit/controllers/settings/general-test.js b/ghost/admin/tests/unit/controllers/settings/general-test.js
index 85beb66983..9fcbbefaeb 100644
--- a/ghost/admin/tests/unit/controllers/settings/general-test.js
+++ b/ghost/admin/tests/unit/controllers/settings/general-test.js
@@ -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');
- });
}
);
diff --git a/ghost/admin/tests/unit/services/event-bus-test.js b/ghost/admin/tests/unit/services/event-bus-test.js
new file mode 100644
index 0000000000..aa284974ff
--- /dev/null
+++ b/ghost/admin/tests/unit/services/event-bus-test.js
@@ -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;
+ });
+ }
+);