diff --git a/ghost/admin/app/index.html b/ghost/admin/app/index.html index fa6e6f1bec..b055a733a8 100644 --- a/ghost/admin/app/index.html +++ b/ghost/admin/app/index.html @@ -32,7 +32,7 @@ - + {{content-for "head-footer"}} diff --git a/ghost/admin/app/routes/application.js b/ghost/admin/app/routes/application.js index ae3e4c151b..60a35eb6bb 100644 --- a/ghost/admin/app/routes/application.js +++ b/ghost/admin/app/routes/application.js @@ -3,6 +3,8 @@ import {htmlSafe} from 'ember-string'; import injectService from 'ember-service/inject'; import run from 'ember-runloop'; import {isEmberArray} from 'ember-array/utils'; +import observer from 'ember-metal/observer'; +import $ from 'jquery'; import AuthConfiguration from 'ember-simple-auth/configuration'; import ApplicationRouteMixin from 'ember-simple-auth/mixins/application-route-mixin'; @@ -27,6 +29,7 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { config: injectService(), feature: injectService(), dropdown: injectService(), + lazyLoader: injectService(), notifications: injectService(), upgradeNotification: injectService(), @@ -54,7 +57,11 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { // return the feature loading promise so that we block until settings // are loaded in order for synchronous access everywhere - return this.get('feature').fetch(); + return this.get('feature').fetch().then(() => { + if (this.get('feature.nightShift')) { + return this._setAdminTheme(); + } + }); } }, @@ -88,6 +95,19 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { } }, + _nightShift: observer('feature.nightShift', function () { + this._setAdminTheme(); + }), + + _setAdminTheme() { + let nightShift = this.get('feature.nightShift'); + + return this.get('lazyLoader').loadStyle('dark', 'assets/ghost-dark.css', true).then(() => { + $('link[title=dark]').prop('disabled', !nightShift); + $('link[title=light]').prop('disabled', nightShift); + }); + }, + actions: { openMobileMenu() { this.controller.set('showMobileMenu', true); @@ -113,6 +133,10 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, { signedIn() { this.get('notifications').clearAll(); this.send('loadServerNotifications', true); + + if (this.get('feature.nightShift')) { + this._setAdminTheme(); + } }, invalidateSession() { diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index b6c0b9c6f5..275d6df4ed 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -3,13 +3,20 @@ import Service from 'ember-service'; import computed from 'ember-computed'; import injectService from 'ember-service/inject'; import set from 'ember-metal/set'; +import RSVP from 'rsvp'; // ember-cli-shims doesn't export Error const {Error: EmberError} = Ember; -export function feature(name) { - return computed(`config.${name}`, `labs.${name}`, { +export function feature(name, user = false) { + let watchedProps = user ? [`accessibility.${name}`] : [`config.${name}`, `labs.${name}`]; + + return computed.apply(Ember, watchedProps.concat({ get() { + if (user) { + return this.get(`accessibility.${name}`); + } + if (this.get(`config.${name}`)) { return this.get(`config.${name}`); } @@ -17,21 +24,24 @@ export function feature(name) { return this.get(`labs.${name}`) || false; }, set(key, value) { - this.update(key, value); + this.update(key, value, user); return value; } - }); + })); } export default Service.extend({ store: injectService(), config: injectService(), + session: injectService(), notifications: injectService(), publicAPI: feature('publicAPI'), subscribers: feature('subscribers'), + nightShift: feature('nightShift', true), _settings: null, + _user: null, labs: computed('_settings.labs', function () { let labs = this.get('_settings.labs'); @@ -43,40 +53,57 @@ export default Service.extend({ } }), + accessibility: computed('_user.accessibility', function () { + let accessibility = this.get('_user.accessibility'); + + try { + return JSON.parse(accessibility) || {}; + } catch (e) { + return {}; + } + }), + fetch() { - return this.get('store').queryRecord('setting', {type: 'blog,theme,private'}).then((settings) => { + return RSVP.hash({ + settings: this.get('store').queryRecord('setting', {type: 'blog,theme,private'}), + user: this.get('session.user') + }).then(({settings, user}) => { this.set('_settings', settings); + this.set('_user', user); + return true; }); }, - update(key, value) { - let settings = this.get('_settings'); - let labs = this.get('labs'); + update(key, value, user = false) { + let serviceProperty = user ? 'accessibility' : 'labs'; + let model = this.get(user ? '_user' : '_settings'); + let featureObject = this.get(serviceProperty); - // set the new labs key value - set(labs, key, value); - // update the 'labs' key of the settings model - settings.set('labs', JSON.stringify(labs)); + // set the new key value for either the labs property or the accessibility property + set(featureObject, key, value); - return settings.save().then(() => { + // update the 'labs' or 'accessibility' key of the model + model.set(serviceProperty, JSON.stringify(featureObject)); + + return model.save().then(() => { // return the labs key value that we get from the server - this.notifyPropertyChange('labs'); - return this.get(`labs.${key}`); + this.notifyPropertyChange(serviceProperty); + return this.get(`${serviceProperty}.${key}`); }).catch((error) => { - settings.rollbackAttributes(); - this.notifyPropertyChange('labs'); + model.rollbackAttributes(); + this.notifyPropertyChange(serviceProperty); // we'll always have an errors object unless we hit a // validation error if (!error) { - throw new EmberError('Validation of the feature service settings model failed when updating labs.'); + throw new EmberError(`Validation of the feature service ${user ? 'user' : 'settings'} model failed when updating ${serviceProperty}.`); } this.get('notifications').showAPIError(error); - return this.get(`labs.${key}`); + return this.get(`${serviceProperty}.${key}`); }); } }); diff --git a/ghost/admin/app/services/lazy-loader.js b/ghost/admin/app/services/lazy-loader.js index b853296273..6d8c6896b1 100644 --- a/ghost/admin/app/services/lazy-loader.js +++ b/ghost/admin/app/services/lazy-loader.js @@ -39,7 +39,7 @@ export default Service.extend({ return scriptPromise; }, - loadStyle(key, url) { + loadStyle(key, url, alternate = false) { if (this.get('testing') || $(`#${key}-styles`).length) { return RSVP.resolve(); } @@ -47,10 +47,22 @@ export default Service.extend({ return new RSVP.Promise((resolve, reject) => { let link = document.createElement('link'); link.id = `${key}-styles`; - link.rel = 'stylesheet'; + link.rel = alternate ? 'alternate stylesheet' : 'stylesheet'; link.href = `${this.get('ghostPaths.adminRoot')}${url}`; - link.onload = resolve; + link.onload = () => { + if (alternate) { + // If stylesheet is alternate and we disable the stylesheet before injecting into the DOM, + // the onload handler never gets called. Thus, we should disable the link after it has finished loading + link.disabled = true; + } + resolve(); + }; link.onerror = reject; + + if (alternate) { + link.title = key; + } + $('head').append($(link)); }); } diff --git a/ghost/admin/tests/integration/services/feature-test.js b/ghost/admin/tests/integration/services/feature-test.js index 6738e3db7b..4de3171daa 100644 --- a/ghost/admin/tests/integration/services/feature-test.js +++ b/ghost/admin/tests/integration/services/feature-test.js @@ -43,9 +43,40 @@ function stubSettings(server, labs, validSave = true, validSettings = true) { }); } +function stubUser(server, accessibility, validSave = true) { + let users = [{ + id: '1', + // Add extra properties for the validations + name: 'Test User', + email: 'test@example.com', + accessibility: JSON.stringify(accessibility), + roles: [{ + id: 1, + name: 'Owner', + description: 'Owner' + }] + }]; + + server.get('/ghost/api/v0.1/users/me/', function () { + return [200, {'Content-Type': 'application/json'}, JSON.stringify({users})]; + }); + + server.put('/ghost/api/v0.1/users/1/', function (request) { + let statusCode = (validSave) ? 200 : 400; + let response = (validSave) ? request.requestBody : JSON.stringify({ + errors: [{ + message: 'Test Error' + }] + }); + + return [statusCode, {'Content-Type': 'application/json'}, response]; + }); +} + function addTestFlag() { FeatureService.reopen({ - testFlag: feature('testFlag') + testFlag: feature('testFlag'), + testUserFlag: feature('testUserFlag', true) }); } @@ -64,82 +95,118 @@ describe('Integration: Service: feature', function () { server.shutdown(); }); - it('loads labs settings correctly', function (done) { + it('loads labs and user settings correctly', function () { stubSettings(server, {testFlag: true}); + stubUser(server, {testUserFlag: true}); + addTestFlag(); let service = this.subject(); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('testFlag')).to.be.true; - done(); + expect(service.get('testUserFlag')).to.be.true; }); }); - it('returns false for set flag with config false and labs false', function (done) { + it('returns false for set flag with config false and labs false', function () { stubSettings(server, {testFlag: false}); + stubUser(server, {}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', false); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('labs.testFlag')).to.be.false; expect(service.get('testFlag')).to.be.false; - done(); }); }); - it('returns true for set flag with config true and labs false', function (done) { + it('returns true for set flag with config true and labs false', function () { stubSettings(server, {testFlag: false}); + stubUser(server, {}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', true); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('labs.testFlag')).to.be.false; expect(service.get('testFlag')).to.be.true; - done(); }); }); - it('returns true for set flag with config false and labs true', function (done) { + it('returns true for set flag with config false and labs true', function () { stubSettings(server, {testFlag: true}); + stubUser(server, {}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', false); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('labs.testFlag')).to.be.true; expect(service.get('testFlag')).to.be.true; - done(); }); }); - it('returns true for set flag with config true and labs true', function (done) { + it('returns true for set flag with config true and labs true', function () { stubSettings(server, {testFlag: true}); + stubUser(server, {}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', true); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('labs.testFlag')).to.be.true; expect(service.get('testFlag')).to.be.true; - done(); }); }); - it('saves correctly', function (done) { + it('returns false for set flag with accessibility false', function () { + stubSettings(server, {}); + stubUser(server, {testUserFlag: false}); + + addTestFlag(); + + let service = this.subject(); + + return service.fetch().then(() => { + expect(service.get('accessibility.testUserFlag')).to.be.false; + expect(service.get('testUserFlag')).to.be.false; + }); + }); + + it('returns true for set flag with accessibility true', function () { + stubSettings(server, {}); + stubUser(server, {testUserFlag: true}); + + addTestFlag(); + + let service = this.subject(); + + return service.fetch().then(() => { + expect(service.get('accessibility.testUserFlag')).to.be.true; + expect(service.get('testUserFlag')).to.be.true; + }); + }); + + it('saves labs setting correctly', function () { stubSettings(server, {testFlag: false}); + stubUser(server, {testUserFlag: false}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', false); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('testFlag')).to.be.false; run(() => { @@ -149,19 +216,42 @@ describe('Integration: Service: feature', function () { return wait().then(() => { expect(server.handlers[1].numberOfCalls).to.equal(1); expect(service.get('testFlag')).to.be.true; - done(); }); }); }); - it('notifies for server errors', function (done) { + it('saves accessibility setting correctly', function () { + stubSettings(server, {}); + stubUser(server, {testUserFlag: false}); + + addTestFlag(); + + let service = this.subject(); + + return service.fetch().then(() => { + expect(service.get('testUserFlag')).to.be.false; + + run(() => { + service.set('testUserFlag', true); + }); + + return wait().then(() => { + expect(server.handlers[3].numberOfCalls).to.equal(1); + expect(service.get('testUserFlag')).to.be.true; + }); + }); + }); + + it('notifies for server errors on labs save', function () { stubSettings(server, {testFlag: false}, false); + stubUser(server, {}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', false); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('testFlag')).to.be.false; run(() => { @@ -180,19 +270,51 @@ describe('Integration: Service: feature', function () { ).to.equal(1); expect(service.get('testFlag')).to.be.false; - done(); }); }); }); - it('notifies for validation errors', function (done) { + it('notifies for server errors on accessibility save', function () { + stubSettings(server, {}); + stubUser(server, {testUserFlag: false}, false); + + addTestFlag(); + + let service = this.subject(); + + return service.fetch().then(() => { + expect(service.get('testUserFlag')).to.be.false; + + run(() => { + service.set('testUserFlag', true); + }); + + return wait().then(() => { + expect( + server.handlers[3].numberOfCalls, + 'PUT call is made' + ).to.equal(1); + + expect( + service.get('notifications.alerts').length, + 'number of alerts shown' + ).to.equal(1); + + expect(service.get('testUserFlag')).to.be.false; + }); + }); + }); + + it('notifies for validation errors', function () { stubSettings(server, {testFlag: false}, true, false); + stubUser(server, {}); + addTestFlag(); let service = this.subject(); service.get('config').set('testFlag', false); - service.fetch().then(() => { + return service.fetch().then(() => { expect(service.get('testFlag')).to.be.false; run(() => { @@ -205,7 +327,6 @@ describe('Integration: Service: feature', function () { // ensure validation is happening before the API is hit expect(server.handlers[1].numberOfCalls).to.equal(0); expect(service.get('testFlag')).to.be.false; - done(); }); }); });