From feaaf55a0c93a5da460bfc1d8fd1270fc045a49d Mon Sep 17 00:00:00 2001 From: Neil Lalonde <neillalonde@gmail.com> Date: Wed, 16 Apr 2014 09:49:06 -0400 Subject: [PATCH] Theming: color scheme editing. Unfinished! Doesn't have any effect on css files yet. --- .../admin/components/color_input_component.js | 28 +++++ .../admin_customize_colors_controller.js | 76 ++++++++++++ ...=> admin_customize_css_html_controller.js} | 4 +- .../javascripts/admin/models/color_scheme.js | 115 +++++++++++++++++ .../admin/models/color_scheme_color.js | 71 +++++++++++ .../routes/admin_customize_colors_route.js | 20 +++ .../routes/admin_customize_css_html_route.js | 15 +++ .../admin/routes/admin_customize_route.js | 12 +- .../javascripts/admin/routes/admin_routes.js | 5 +- .../admin/templates/admin.js.handlebars | 2 +- .../admin/templates/customize.js.handlebars | 76 ++---------- .../templates/customize_colors.js.handlebars | 56 +++++++++ .../customize_css_html.js.handlebars | 70 +++++++++++ .../views/admin_customize_colors_view.js | 11 ++ .../admin/views/admin_customize_view.js | 9 +- .../components/color-input.js.handlebars | 1 + .../stylesheets/common/admin/admin_base.scss | 23 ++++ .../admin/color_schemes_controller.rb | 33 +++++ app/models/color_scheme.rb | 51 ++++++++ app/models/color_scheme_color.rb | 99 +++++++++++++++ .../color_scheme_color_serializer.rb | 7 ++ app/serializers/color_scheme_serializer.rb | 9 ++ app/services/color_scheme_revisor.rb | 71 +++++++++++ config/locales/client.en.yml | 15 +++ config/locales/server.en.yml | 3 + config/routes.rb | 5 +- db/fixtures/701_color_schemes.rb | 14 +++ .../20140416202746_create_color_schemes.rb | 13 ++ ...140416202801_create_color_scheme_colors.rb | 14 +++ .../admin/color_schemes_controller_spec.rb | 72 +++++++++++ .../color_scheme_color_fabricator.rb | 6 + spec/fabricators/color_scheme_fabricator.rb | 5 + spec/models/color_scheme_spec.rb | 43 +++++++ spec/services/color_scheme_revisor_spec.rb | 116 ++++++++++++++++++ 34 files changed, 1086 insertions(+), 84 deletions(-) create mode 100644 app/assets/javascripts/admin/components/color_input_component.js create mode 100644 app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js rename app/assets/javascripts/admin/controllers/{admin_customize_controller.js => admin_customize_css_html_controller.js} (92%) create mode 100644 app/assets/javascripts/admin/models/color_scheme.js create mode 100644 app/assets/javascripts/admin/models/color_scheme_color.js create mode 100644 app/assets/javascripts/admin/routes/admin_customize_colors_route.js create mode 100644 app/assets/javascripts/admin/routes/admin_customize_css_html_route.js create mode 100644 app/assets/javascripts/admin/templates/customize_colors.js.handlebars create mode 100644 app/assets/javascripts/admin/templates/customize_css_html.js.handlebars create mode 100644 app/assets/javascripts/admin/views/admin_customize_colors_view.js create mode 100644 app/assets/javascripts/discourse/templates/components/color-input.js.handlebars create mode 100644 app/controllers/admin/color_schemes_controller.rb create mode 100644 app/models/color_scheme.rb create mode 100644 app/models/color_scheme_color.rb create mode 100644 app/serializers/color_scheme_color_serializer.rb create mode 100644 app/serializers/color_scheme_serializer.rb create mode 100644 app/services/color_scheme_revisor.rb create mode 100644 db/fixtures/701_color_schemes.rb create mode 100644 db/migrate/20140416202746_create_color_schemes.rb create mode 100644 db/migrate/20140416202801_create_color_scheme_colors.rb create mode 100644 spec/controllers/admin/color_schemes_controller_spec.rb create mode 100644 spec/fabricators/color_scheme_color_fabricator.rb create mode 100644 spec/fabricators/color_scheme_fabricator.rb create mode 100644 spec/models/color_scheme_spec.rb create mode 100644 spec/services/color_scheme_revisor_spec.rb diff --git a/app/assets/javascripts/admin/components/color_input_component.js b/app/assets/javascripts/admin/components/color_input_component.js new file mode 100644 index 000000000..b1626dc46 --- /dev/null +++ b/app/assets/javascripts/admin/components/color_input_component.js @@ -0,0 +1,28 @@ +/** + An input field for a color. + + @param hexValue is a reference to the color's hex value. + + @class Discourse.ColorInputComponent + @extends Ember.Component + @namespace Discourse + @module Discourse + **/ +Discourse.ColorInputComponent = Ember.Component.extend({ + layoutName: 'components/color-input', + + hexValueChanged: function() { + var hex = this.get('hexValue'); + if (hex && (hex.length === 3 || hex.length === 6) && this.get('brightnessValue')) { + this.$('input').attr('style', 'color: ' + (this.get('brightnessValue') > 125 ? 'black' : 'white') + '; background-color: #' + hex + ';'); + } + }.observes('hexValue', 'brightnessValue'), + + didInsertElement: function() { + var self = this; + this._super(); + Em.run.schedule('afterRender', function() { + self.hexValueChanged(); + }); + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js b/app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js new file mode 100644 index 000000000..b199a9057 --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin_customize_colors_controller.js @@ -0,0 +1,76 @@ +/** + This controller supports interface for creating custom CSS skins in Discourse. + + @class AdminCustomizeColorsController + @extends Ember.Controller + @namespace Discourse + @module Discourse +**/ +Discourse.AdminCustomizeColorsController = Ember.ArrayController.extend({ + + baseColorScheme: function() { + return this.get('model').findBy('id', 1); + }.property('model.@each.id'), + + removeSelected: function() { + this.removeObject(this.get('selectedItem')); + this.set('selectedItem', null); + }, + + actions: { + selectColorScheme: function(colorScheme) { + if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); } + this.set('selectedItem', colorScheme); + colorScheme.set('savingStatus', null); + colorScheme.set('selected', true); + }, + + newColorScheme: function() { + var newColorScheme = Em.copy(this.get('baseColorScheme'), true); + newColorScheme.set('name', I18n.t('admin.customize.colors.new_name')); + this.pushObject(newColorScheme); + this.send('selectColorScheme', newColorScheme); + }, + + undo: function(color) { + color.undo(); + }, + + save: function() { + var selectedItem = this.get('selectedItem'); + selectedItem.save(); + if (selectedItem.get('enabled')) { + this.get('model').forEach(function(c) { + if (c !== selectedItem) { + c.set('enabled', false); + c.startTrackingChanges(); + c.notifyPropertyChange('description'); + } + }); + } + }, + + copy: function(colorScheme) { + var newColorScheme = Em.copy(colorScheme, true); + newColorScheme.set('name', I18n.t('admin.customize.colors.copy_name_prefix') + ' ' + colorScheme.get('name')); + this.pushObject(newColorScheme); + this.send('selectColorScheme', newColorScheme); + }, + + destroy: function() { + var self = this, + item = self.get('selectedItem'); + + return bootbox.confirm(I18n.t("admin.customize.colors.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) { + if (result) { + if (item.get('newRecord')) { + self.removeSelected(); + } else { + item.destroy().then(function(){ self.removeSelected(); }); + } + } + }); + } + } + +}); diff --git a/app/assets/javascripts/admin/controllers/admin_customize_controller.js b/app/assets/javascripts/admin/controllers/admin_customize_css_html_controller.js similarity index 92% rename from app/assets/javascripts/admin/controllers/admin_customize_controller.js rename to app/assets/javascripts/admin/controllers/admin_customize_css_html_controller.js index 9155200f5..6733bf9af 100644 --- a/app/assets/javascripts/admin/controllers/admin_customize_controller.js +++ b/app/assets/javascripts/admin/controllers/admin_customize_css_html_controller.js @@ -1,12 +1,12 @@ /** This controller supports interface for creating custom CSS skins in Discourse. - @class AdminCustomizeController + @class AdminCustomizeCssHtmlController @extends Ember.Controller @namespace Discourse @module Discourse **/ -Discourse.AdminCustomizeController = Ember.ArrayController.extend({ +Discourse.AdminCustomizeCssHtmlController = Ember.ArrayController.extend({ actions: { diff --git a/app/assets/javascripts/admin/models/color_scheme.js b/app/assets/javascripts/admin/models/color_scheme.js new file mode 100644 index 000000000..053cad227 --- /dev/null +++ b/app/assets/javascripts/admin/models/color_scheme.js @@ -0,0 +1,115 @@ +/** + Our data model for a color scheme. + + @class ColorScheme + @extends Discourse.Model + @namespace Discourse + @module Discourse +**/ +Discourse.ColorScheme = Discourse.Model.extend(Ember.Copyable, { + + init: function() { + this._super(); + this.startTrackingChanges(); + }, + + description: function() { + return "" + this.name + (this.enabled ? ' (*)' : ''); + }.property(), + + startTrackingChanges: function() { + this.set('originals', { + name: this.get('name'), + enabled: this.get('enabled') + }); + }, + + copy: function() { + var newScheme = Discourse.ColorScheme.create({name: this.get('name'), enabled: false, can_edit: true, colors: Em.A()}); + _.each(this.get('colors'), function(c){ + newScheme.colors.pushObject(Discourse.ColorSchemeColor.create({name: c.get('name'), hex: c.get('hex'), opacity: c.get('opacity')})); + }); + return newScheme; + }, + + changed: function() { + if (!this.originals) return false; + if (this.originals['name'] !== this.get('name') || this.originals['enabled'] !== this.get('enabled')) return true; + if (_.any(this.get('colors'), function(c){ return c.get('changed'); })) return true; + return false; + }.property('name', 'enabled', 'colors.@each.changed', 'saving'), + + disableSave: function() { + return !this.get('changed') || this.get('saving'); + }.property('changed'), + + newRecord: function() { + return (!this.get('id')); + }.property('id'), + + save: function() { + var self = this; + this.set('savingStatus', I18n.t('saving')); + this.set('saving',true); + + var data = { name: this.name, enabled: this.enabled }; + + data.colors = []; + _.each(this.get('colors'), function(c) { + if (!self.id || c.get('changed')) { + data.colors.pushObject({name: c.get('name'), hex: c.get('hex'), opacity: c.get('opacity')}); + } + }); + + return Discourse.ajax("/admin/color_schemes" + (this.id ? '/' + this.id : '') + '.json', { + data: JSON.stringify({"color_scheme": data}), + type: this.id ? 'PUT' : 'POST', + dataType: 'json', + contentType: 'application/json' + }).then(function(result) { + if(result.id) { self.set('id', result.id); } + self.startTrackingChanges(); + _.each(self.get('colors'), function(c) { + c.startTrackingChanges(); + }); + self.set('savingStatus', I18n.t('saved')); + self.set('saving', false); + self.notifyPropertyChange('description'); + }); + }, + + destroy: function() { + if (this.id) { + return Discourse.ajax("/admin/color_schemes/" + this.id, { type: 'DELETE' }); + } + } + +}); + +var ColorSchemes = Ember.ArrayProxy.extend({ + selectedItemChanged: function() { + var selected = this.get('selectedItem'); + _.each(this.get('content'),function(i) { + return i.set('selected', selected === i); + }); + }.observes('selectedItem') +}); + +Discourse.ColorScheme.reopenClass({ + findAll: function() { + var colorSchemes = ColorSchemes.create({ content: [], loading: true }); + Discourse.ajax('/admin/color_schemes').then(function(all) { + _.each(all, function(colorScheme){ + colorSchemes.pushObject(Discourse.ColorScheme.create({ + id: colorScheme.id, + name: colorScheme.name, + enabled: colorScheme.enabled, + can_edit: colorScheme.can_edit, + colors: colorScheme.colors.map(function(c) { return Discourse.ColorSchemeColor.create({name: c.name, hex: c.hex, opacity: c.opacity}); }) + })); + }); + colorSchemes.set('loading', false); + }); + return colorSchemes; + } +}); diff --git a/app/assets/javascripts/admin/models/color_scheme_color.js b/app/assets/javascripts/admin/models/color_scheme_color.js new file mode 100644 index 000000000..9c6420a75 --- /dev/null +++ b/app/assets/javascripts/admin/models/color_scheme_color.js @@ -0,0 +1,71 @@ +/** + Our data model for a color within a color scheme. + (It's a funny name for a class, but Color seemed too generic for what this class is.) + + @class ColorSchemeColor + @extends Discourse.Model + @namespace Discourse + @module Discourse +**/ +Discourse.ColorSchemeColor = Discourse.Model.extend({ + + init: function() { + this._super(); + this.startTrackingChanges(); + }, + + startTrackingChanges: function() { + this.set('originals', { + hex: this.get('hex') || 'FFFFFF', + opacity: this.get('opacity') || '100' + }); + this.notifyPropertyChange('hex'); // force changed property to be recalculated + }, + + changed: function() { + if (!this.originals) return false; + + if (this.get('hex') !== this.originals['hex'] || this.get('opacity').toString() !== this.originals['opacity'].toString()) { + return true; + } else { + return false; + } + }.property('hex', 'opacity'), + + undo: function() { + if (this.originals) { + this.set('hex', this.originals['hex']); + this.set('opacity', this.originals['opacity']); + } + }, + + /** + brightness returns a number between 0 (darkest) to 255 (brightest). + Undefined if hex is not a valid color. + + @property brightness + **/ + brightness: function() { + var hex = this.get('hex'); + if (hex.length === 6 || hex.length === 3) { + if (hex.length === 3) { + hex = hex.substr(0,1) + hex.substr(0,1) + hex.substr(1,1) + hex.substr(1,1) + hex.substr(2,1) + hex.substr(2,1); + } + return Math.round(((parseInt('0x'+hex.substr(0,2)) * 299) + (parseInt('0x'+hex.substr(2,2)) * 587) + (parseInt('0x'+hex.substr(4,2)) * 114)) /1000); + } + }.property('hex'), + + hexValueChanged: function() { + if (this.get('hex')) { + this.set('hex', this.get('hex').toString().replace(/[^0-9a-fA-F]/g, "")); + } + }.observes('hex'), + + opacityChanged: function() { + if (this.get('opacity')) { + var o = this.get('opacity').toString().replace(/[^\d.]/g, ""); + if (parseInt(o,10) > 100) { o = o.substr(0,o.length-1); } + this.set('opacity', o); + } + }.observes('opacity') +}); diff --git a/app/assets/javascripts/admin/routes/admin_customize_colors_route.js b/app/assets/javascripts/admin/routes/admin_customize_colors_route.js new file mode 100644 index 000000000..953031ab4 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_customize_colors_route.js @@ -0,0 +1,20 @@ +/** + Handles routes related to colors customization + + @class AdminCustomizeColorsRoute + @extends Discourse.Route + @namespace Discourse + @module Discourse +**/ +Discourse.AdminCustomizeColorsRoute = Discourse.Route.extend({ + + model: function() { + return Discourse.ColorScheme.findAll(); + }, + + deactivate: function() { + this._super(); + this.controllerFor('adminCustomizeColors').set('selectedItem', null); + }, + +}); diff --git a/app/assets/javascripts/admin/routes/admin_customize_css_html_route.js b/app/assets/javascripts/admin/routes/admin_customize_css_html_route.js new file mode 100644 index 000000000..ca4594423 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin_customize_css_html_route.js @@ -0,0 +1,15 @@ +/** + Handles routes related to css/html customization + + @class AdminCustomizeCssHtmlRoute + @extends Discourse.Route + @namespace Discourse + @module Discourse +**/ +Discourse.AdminCustomizeCssHtmlRoute = Discourse.Route.extend({ + + model: function() { + return Discourse.SiteCustomization.findAll(); + } + +}); diff --git a/app/assets/javascripts/admin/routes/admin_customize_route.js b/app/assets/javascripts/admin/routes/admin_customize_route.js index 59e26166c..d7eca41d8 100644 --- a/app/assets/javascripts/admin/routes/admin_customize_route.js +++ b/app/assets/javascripts/admin/routes/admin_customize_route.js @@ -1,15 +1,13 @@ /** Handles routes related to customization - @class AdminCustomizeRoute + @class AdminCustomizeIndexRoute @extends Discourse.Route @namespace Discourse @module Discourse **/ -Discourse.AdminCustomizeRoute = Discourse.Route.extend({ - - model: function() { - return Discourse.SiteCustomization.findAll(); +Discourse.AdminCustomizeIndexRoute = Discourse.Route.extend({ + redirect: function() { + this.transitionTo('adminCustomize.css_html'); } - -}); +}); \ No newline at end of file diff --git a/app/assets/javascripts/admin/routes/admin_routes.js b/app/assets/javascripts/admin/routes/admin_routes.js index bc2a431a2..71f687963 100644 --- a/app/assets/javascripts/admin/routes/admin_routes.js +++ b/app/assets/javascripts/admin/routes/admin_routes.js @@ -22,7 +22,10 @@ Discourse.Route.buildRoutes(function() { this.route('previewDigest', { path: '/preview-digest' }); }); - this.route('customize'); + this.resource('adminCustomize', { path: '/customize' } ,function() { + this.route('colors'); + this.route('css_html'); + }); this.route('api'); this.resource('admin.backups', { path: '/backups' }, function() { diff --git a/app/assets/javascripts/admin/templates/admin.js.handlebars b/app/assets/javascripts/admin/templates/admin.js.handlebars index 84b4ca72c..85a695066 100644 --- a/app/assets/javascripts/admin/templates/admin.js.handlebars +++ b/app/assets/javascripts/admin/templates/admin.js.handlebars @@ -20,7 +20,7 @@ <li>{{#link-to 'adminFlags'}}{{i18n admin.flags.title}}{{/link-to}}</li> <li>{{#link-to 'adminLogs'}}{{i18n admin.logs.title}}{{/link-to}}</li> {{#if currentUser.admin}} - <li>{{#link-to 'admin.customize'}}{{i18n admin.customize.title}}{{/link-to}}</li> + <li>{{#link-to 'adminCustomize'}}{{i18n admin.customize.title}}{{/link-to}}</li> <li>{{#link-to 'admin.api'}}{{i18n admin.api.title}}{{/link-to}}</li> <li>{{#link-to 'admin.backups'}}{{i18n admin.backups.title}}{{/link-to}}</li> {{/if}} diff --git a/app/assets/javascripts/admin/templates/customize.js.handlebars b/app/assets/javascripts/admin/templates/customize.js.handlebars index b4150ca11..acc2f5ecf 100644 --- a/app/assets/javascripts/admin/templates/customize.js.handlebars +++ b/app/assets/javascripts/admin/templates/customize.js.handlebars @@ -1,70 +1,12 @@ -<div class='content-list span6'> - <h3>{{i18n admin.customize.long_title}}</h3> - <ul> - {{#each model}} - <li><a {{action selectStyle this}} {{bind-attr class="this.selected:active"}}>{{this.description}}</a></li> - {{/each}} - </ul> - <button {{action newCustomization}} class='btn'>{{i18n admin.customize.new}}</button> +<div class='admin-controls'> + <div class='span15'> + <ul class="nav nav-pills"> + <li>{{#link-to 'adminCustomize.colors'}}{{i18n admin.customize.colors.title}}{{/link-to}}</li> + <li>{{#link-to 'adminCustomize.css_html'}}{{i18n admin.customize.css_html.title}}{{/link-to}}</li> + </ul> + </div> </div> - -{{#if selectedItem}} -<div class='current-style'> - {{#with selectedItem}} - {{textField class="style-name" value=name}} - - <div class='admin-controls'> - <ul class="nav nav-pills"> - <li> - <a {{bind-attr class="view.stylesheetActive:active"}}{{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a> - </li> - <li> - <a {{bind-attr class="view.headerActive:active"}}{{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a> - </li> - <li> - <a {{bind-attr class="view.mobileStylesheetActive:active"}}{{action selectMobileStylesheet href="true" target="view"}}>{{i18n admin.customize.mobile_css}}</a> - </li> - <li> - <a {{bind-attr class="view.mobileHeaderActive:active"}}{{action selectMobileHeader href="true" target="view"}}>{{i18n admin.customize.mobile_header}}</a> - </li> - </ul> - </div> - - <div class="admin-container"> - {{#if view.headerActive}} - {{aceEditor content=header mode="html"}} - {{/if}} - {{#if view.stylesheetActive}} - {{aceEditor content=stylesheet mode="scss"}} - {{/if}} - {{#if view.mobileHeaderActive}} - {{aceEditor content=mobile_header mode="html"}} - {{/if}} - {{#if view.mobileStylesheetActive}} - {{aceEditor content=mobile_stylesheet mode="scss"}} - {{/if}} - </div> - {{/with}} - <br> - <div class='status-actions'> - <span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="selectedItem.override_default_style"}}</span> - <span>{{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="selectedItem.enabled"}}</span> - {{#unless selectedItem.changed}} - <a class='preview-link' {{bind-attr href="selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a> - | - <a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br> - {{/unless}} - </div> - - <div class='buttons'> - <button {{action save}} {{bind-attr disabled="selectedItem.disableSave"}} class='btn'>{{i18n admin.customize.save}}</button> - <span class='saving'>{{selectedItem.savingStatus}}</span> - <a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a> - </div> - +<div class="admin-container"> + {{outlet}} </div> -{{else}} - <p class="about">{{i18n admin.customize.about}}</p> -{{/if}} -<div class='clearfix'></div> diff --git a/app/assets/javascripts/admin/templates/customize_colors.js.handlebars b/app/assets/javascripts/admin/templates/customize_colors.js.handlebars new file mode 100644 index 000000000..9cdeebc2f --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize_colors.js.handlebars @@ -0,0 +1,56 @@ +<div class='alert alert-error'>{{i18n admin.customize.colors.under_construction}}</div> + +<div class='content-list span6'> + <h3>{{i18n admin.customize.colors.long_title}}</h3> + <ul> + {{#each model}} + {{#if can_edit}} + <li><a {{action selectColorScheme this}} {{bind-attr class="selected:active"}}>{{description}}</a></li> + {{/if}} + {{/each}} + </ul> + <button {{action newColorScheme}} class='btn'>{{i18n admin.customize.new}}</button> +</div> + +{{#if selectedItem}} + {{#with selectedItem}} + <div class="current-style color-scheme"> + <div class="admin-container"> + <h1>{{textField class="style-name" value=name}}</h1> + + <div class="controls"> + <button {{action save}} {{bind-attr disabled="disableSave"}} class='btn'>{{i18n admin.customize.save}}</button> + <span {{bind-attr class=":saving savingStatus::hidden" }}>{{savingStatus}}</span> + <button {{action copy this}} class='btn'><i class="fa fa-copy"></i> {{i18n admin.customize.copy}}</button> + <span>{{view Ember.Checkbox checkedBinding="enabled"}} {{i18n admin.customize.enabled}}</span> + <a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a> + </div> + + <table class="table colors"> + <thead> + <tr> + <th></th> + <th class="hex">{{i18n admin.customize.color}}</th> + <th class="opacity">{{i18n admin.customize.opacity}}</th> + <th></th> + </tr> + </thead> + <tbody> + {{#each colors}} + <tr {{bind-attr class="changed"}}> + <td class="name">{{name}}</td> + <td class="hex">{{color-input hexValue=hex brightnessValue=brightness}}</td> + <td class="opacity">{{textField class="opacity-input" value=opacity maxlength="3"}}</td> + <td class="changed"> + <button {{bind-attr class=":btn :undo changed::invisible"}} {{action undo this}}>undo</button> + </td> + </tr> + {{/each}} + </tbody> + </table> + </div> + </div> + {{/with}} +{{else}} + <p class="about">{{i18n admin.customize.colors.about}}</p> +{{/if}} \ No newline at end of file diff --git a/app/assets/javascripts/admin/templates/customize_css_html.js.handlebars b/app/assets/javascripts/admin/templates/customize_css_html.js.handlebars new file mode 100644 index 000000000..5042cfa00 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize_css_html.js.handlebars @@ -0,0 +1,70 @@ +<div class='content-list span6'> + <h3>{{i18n admin.customize.css_html.long_title}}</h3> + <ul> + {{#each model}} + <li><a {{action selectStyle this}} {{bind-attr class="this.selected:active"}}>{{this.description}}</a></li> + {{/each}} + </ul> + <button {{action newCustomization}} class='btn'>{{i18n admin.customize.new}}</button> +</div> + + +{{#if selectedItem}} +<div class='current-style'> + {{#with selectedItem}} + {{textField class="style-name" value=name}} + + <div class='admin-controls'> + <ul class="nav nav-pills"> + <li> + <a {{bind-attr class="view.stylesheetActive:active"}}{{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a> + </li> + <li> + <a {{bind-attr class="view.headerActive:active"}}{{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a> + </li> + <li> + <a {{bind-attr class="view.mobileStylesheetActive:active"}}{{action selectMobileStylesheet href="true" target="view"}}>{{i18n admin.customize.mobile_css}}</a> + </li> + <li> + <a {{bind-attr class="view.mobileHeaderActive:active"}}{{action selectMobileHeader href="true" target="view"}}>{{i18n admin.customize.mobile_header}}</a> + </li> + </ul> + </div> + + <div class="admin-container"> + {{#if view.headerActive}} + {{aceEditor content=header mode="html"}} + {{/if}} + {{#if view.stylesheetActive}} + {{aceEditor content=stylesheet mode="scss"}} + {{/if}} + {{#if view.mobileHeaderActive}} + {{aceEditor content=mobile_header mode="html"}} + {{/if}} + {{#if view.mobileStylesheetActive}} + {{aceEditor content=mobile_stylesheet mode="scss"}} + {{/if}} + </div> + {{/with}} + <br> + <div class='status-actions'> + <span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="selectedItem.override_default_style"}}</span> + <span>{{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="selectedItem.enabled"}}</span> + {{#unless selectedItem.changed}} + <a class='preview-link' {{bind-attr href="selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a> + | + <a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br> + {{/unless}} + </div> + + <div class='buttons'> + <button {{action save}} {{bind-attr disabled="selectedItem.disableSave"}} class='btn'>{{i18n admin.customize.save}}</button> + <span class='saving'>{{selectedItem.savingStatus}}</span> + <a {{action destroy}} class='delete-link'>{{i18n admin.customize.delete}}</a> + </div> + +</div> +{{else}} + <p class="about">{{i18n admin.customize.about}}</p> +{{/if}} +<div class='clearfix'></div> diff --git a/app/assets/javascripts/admin/views/admin_customize_colors_view.js b/app/assets/javascripts/admin/views/admin_customize_colors_view.js new file mode 100644 index 000000000..e503a6f6d --- /dev/null +++ b/app/assets/javascripts/admin/views/admin_customize_colors_view.js @@ -0,0 +1,11 @@ +/** + A view to handle color selections within a site customization + + @class AdminCustomizeColorsView + @extends Discourse.View + @namespace Discourse + @module Discourse +**/ +Discourse.AdminCustomizeColorsView = Discourse.View.extend({ + templateName: 'admin/templates/customize_colors' +}); diff --git a/app/assets/javascripts/admin/views/admin_customize_view.js b/app/assets/javascripts/admin/views/admin_customize_view.js index 767559bf7..de6b7eab9 100644 --- a/app/assets/javascripts/admin/views/admin_customize_view.js +++ b/app/assets/javascripts/admin/views/admin_customize_view.js @@ -12,15 +12,14 @@ Discourse.AdminCustomizeView = Discourse.View.extend({ templateName: 'admin/templates/customize', classNames: ['customize'], selected: 'stylesheet', - headerActive: Em.computed.equal('selected', 'header'), - stylesheetActive: Em.computed.equal('selected', 'stylesheet'), + headerActive: Em.computed.equal('selected', 'header'), + stylesheetActive: Em.computed.equal('selected', 'stylesheet'), mobileHeaderActive: Em.computed.equal('selected', 'mobileHeader'), mobileStylesheetActive: Em.computed.equal('selected', 'mobileStylesheet'), actions: { - selectHeader: function() { this.set('selected', 'header'); }, - selectStylesheet: function() { this.set('selected', 'stylesheet'); }, - + selectHeader: function() { this.set('selected', 'header'); }, + selectStylesheet: function() { this.set('selected', 'stylesheet'); }, selectMobileHeader: function() { this.set('selected', 'mobileHeader'); }, selectMobileStylesheet: function() { this.set('selected', 'mobileStylesheet'); } }, diff --git a/app/assets/javascripts/discourse/templates/components/color-input.js.handlebars b/app/assets/javascripts/discourse/templates/components/color-input.js.handlebars new file mode 100644 index 000000000..18e2b107c --- /dev/null +++ b/app/assets/javascripts/discourse/templates/components/color-input.js.handlebars @@ -0,0 +1 @@ +{{textField class="hex-input" value=hexValue maxlength="6"}} diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss index 8010dcf0a..6412c8369 100644 --- a/app/assets/stylesheets/common/admin/admin_base.scss +++ b/app/assets/stylesheets/common/admin/admin_base.scss @@ -421,6 +421,25 @@ section.details { } } } + .color-scheme { + .controls { + span, button, a { + margin-right: 10px; + } + } + } + .colors { + thead th { border: none; } + td.hex { width: 100px; } + .hex-input { width: 80px; } + td.opacity { width: 50px; } + .opacity-input { width: 30px; } + .hex, .opacity { text-align: center; } + + .changed .name { + color: darken($highlight_text_color, 30%); + } + } } @@ -790,12 +809,16 @@ table.api-keys { color: $primary_text_color; font-size: 15px; padding-left: 5px; + margin-bottom: 10px; } ul { list-style: none; margin: 0; + li:first-of-type { + border-top: 1px solid $primary_border_color; + } li { border-bottom: 1px solid $primary_border_color; } diff --git a/app/controllers/admin/color_schemes_controller.rb b/app/controllers/admin/color_schemes_controller.rb new file mode 100644 index 000000000..569391bb5 --- /dev/null +++ b/app/controllers/admin/color_schemes_controller.rb @@ -0,0 +1,33 @@ +class Admin::ColorSchemesController < Admin::AdminController + + before_filter :fetch_color_scheme, only: [:update, :destroy] + + def index + render_serialized(ColorScheme.current_version.order('id ASC').all.to_a, ColorSchemeSerializer) + end + + def create + color_scheme = ColorScheme.create(color_scheme_params) + render json: color_scheme, root: false + end + + def update + render json: ColorSchemeRevisor.revise(@color_scheme, color_scheme_params), root: false + end + + def destroy + @color_scheme.destroy + render json: success_json + end + + + private + + def fetch_color_scheme + @color_scheme = ColorScheme.find(params[:id]) + end + + def color_scheme_params + params.permit(color_scheme: [:enabled, :name, colors: [:name, :hex, :opacity]])[:color_scheme] + end +end diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb new file mode 100644 index 000000000..b1f9b000a --- /dev/null +++ b/app/models/color_scheme.rb @@ -0,0 +1,51 @@ +class ColorScheme < ActiveRecord::Base + + has_many :color_scheme_colors, -> { order('id ASC') }, dependent: :destroy + + alias_method :colors, :color_scheme_colors + + scope :current_version, ->{ where(versioned_id: nil) } + + after_destroy :destroy_versions + + def self.enabled + current_version.where(enabled: true).first || find(1) + end + + def can_edit? + self.id != 1 # base theme shouldn't be edited, except by seed data + end + + def colors=(arr) + @colors_by_name = nil + arr.each do |c| + self.color_scheme_colors << ColorSchemeColor.new( + name: c[:name], + hex: c[:hex], + opacity: c[:opacity].to_i + ) + end + end + + def colors_by_name + @colors_by_name ||= self.colors.inject({}) { |sum,c| sum[c.name] = c; sum; } + end + def clear_colors_cache + @colors_by_name = nil + end + + def colors_hashes + color_scheme_colors.map do |c| + {name: c.name, hex: c.hex, opacity: c.opacity} + end + end + + def previous_version + ColorScheme.where(versioned_id: self.id).where('version < ?', self.version).order('version DESC').first + end + + def destroy_versions + ColorScheme.where(versioned_id: self.id).destroy_all + end + +end diff --git a/app/models/color_scheme_color.rb b/app/models/color_scheme_color.rb new file mode 100644 index 000000000..4bcb5b001 --- /dev/null +++ b/app/models/color_scheme_color.rb @@ -0,0 +1,99 @@ +class ColorSchemeColor < ActiveRecord::Base + belongs_to :color_scheme + + BASE_COLORS = { + primary_border_color: "e6e6e6", + secondary_border_color: "f5f5f5", + tertiary_border_color: "ffffff", + highlight_border_color: "ffff4d", + emphasis_border_color: "00aaff", + warning_border_color: "f0a28f", + success_border_color: "009900", + primary_background_color: "ffffff", + secondary_background_color: "333333", + tertiary_background_color: "4d4d4d", + moderator_background_color: "ffffe5", + emphasis_background_color: "e5f6ff", + success_background_color: "99ff99", + warning_background_color: "f6c7bc", + highlight_background_color: "ffffc2", + like_background_color: "fee7ed", + composer_background_color: "e6e6e6", + notification_badge_background_color: "8c8c8c", + primary_text_color: "333333", + secondary_text_color: "999999", + tertiary_text_color: "ffffff", + warning_text_color: "e45735", + success_text_color: "009900", + emphasis_text_color: "00aaff", + highlight_text_color: "ffff4d", + like_color: "fa6c8d", + primary_shadow_color: "333333", + secondary_shadow_color: "ffffff", + warning_shadow_color: "f0a28f", + success_shadow_color: "009900", + highlight: "ffff4d", + header_item_highlight: "e5f6ff", + link_color: "0088cc", + secondary_link_color: "ffffff", + "muted-link-color" => "8c8c8c", + "muted-important-link-color" => "8c8c8c", + "link-color-visited" => "0088cc", + "link-color-hover" => "0088cc", + "link-color-active" => "0088cc", + heatmap_high: "ea7c62", + heatmap_med: "e45735", + heatmap_low: "cb3d1b", + coldmap_high: "33bbff", + coldmap_med: "00aaff", + coldmap_low: "0088cc", + "btn-default-color" => "333333", + "btn-default-background-color" => "b3b3b3", + "btn-default-background-color-hover" => "f5f5f5", + "btn-primary-border-color" => "0088cc", + "btn-primary-background-color" => "00aaff", + "btn-primary-background-color-dark" => "00aaff", + "btn-primary-background-color-light" => "99ddff", + "btn-danger-border-color" => "cb3d1b", + "btn-danger-background-color" => "e45735", + "btn-danger-background-color-dark" => "cb3d1b", + "btn-danger-background-color-light" => "e45735", + "btn-success-background" => "00b300", + "nav-pills-color" => "333333", + "nav-pills-color-hover" => "e45735", + "nav-pills-border-color-hover" => "f9dad2", + "nav-pills-background-color-hover" => "f9dad2", + "nav-pills-color-active" => "e45735", + "nav-pills-border-color-active" => "cb3d1b", + "nav-pills-background-color-active" => "e45735", + "nav-stacked-color" => "333333", + "nav-stacked-border-color" => "cccccc", + "nav-stacked-background-color" => "f5f5f5", + "nav-stacked-divider-color" => "cccccc", + "nav-stacked-chevron-color" => "b3b3b3", + "nav-stacked-border-color-active" => "e45735", + "nav-stacked-background-color-active" => "e45735", + "nav-button-color-hover" => "333333", + "nav-button-background-color-hover" => "cccccc", + "nav-button-color-active" => "333333", + "nav-button-background-color-active" => "cccccc", + "modal-header-color" => "e45735", + "modal-header-border-color" => "b3b3b3", + "modal-close-button-color" => "b3b3b3", + "nav-like-button-color-hover" => "fa6c8d", + "nav-like-button-background-color-hover" => "fed9e1", + "nav-like-button-color-active" => "f83b67", + "nav-like-button-background-color-active" => "fed9e1", + "topic-list-border-color" => "b3b3b3", + "topic-list-th-color" => "8c8c8c", + "topic-list-th-border-color" => "b3b3b3", + "topic-list-th-background-color" => "f5f5f5", + "topic-list-td-color" => "8c8c8c", + "topic-list-td-border-color" => "cccccc", + "topic-list-star-color" => "cccccc", + "topic-list-starred-color" => "e45735", + "quote-background" => "f5f5f5", + topicMenuColor: "333333", + bookmarkColor: "00aaff" + } +end diff --git a/app/serializers/color_scheme_color_serializer.rb b/app/serializers/color_scheme_color_serializer.rb new file mode 100644 index 000000000..53544688f --- /dev/null +++ b/app/serializers/color_scheme_color_serializer.rb @@ -0,0 +1,7 @@ +class ColorSchemeColorSerializer < ApplicationSerializer + attributes :name, :hex, :opacity + + def hex + object.hex # otherwise something crazy is returned + end +end diff --git a/app/serializers/color_scheme_serializer.rb b/app/serializers/color_scheme_serializer.rb new file mode 100644 index 000000000..75394fa5e --- /dev/null +++ b/app/serializers/color_scheme_serializer.rb @@ -0,0 +1,9 @@ +class ColorSchemeSerializer < ApplicationSerializer + attributes :id, :name, :enabled, :can_edit + + has_many :colors, serializer: ColorSchemeColorSerializer, embed: :objects + + def can_edit + object.can_edit? + end +end \ No newline at end of file diff --git a/app/services/color_scheme_revisor.rb b/app/services/color_scheme_revisor.rb new file mode 100644 index 000000000..32dffcd1d --- /dev/null +++ b/app/services/color_scheme_revisor.rb @@ -0,0 +1,71 @@ +class ColorSchemeRevisor + + def initialize(color_scheme, params={}) + @color_scheme = color_scheme + @params = params + end + + def self.revise(color_scheme, params) + self.new(color_scheme, params).revise + end + + def self.revert(color_scheme) + self.new(color_scheme).revert + end + + def revise + ColorScheme.transaction do + if @params[:enabled] + ColorScheme.update_all enabled: false + end + + @color_scheme.name = @params[:name] + @color_scheme.enabled = @params[:enabled] + new_version = false + + if @params[:colors] + new_version = @params[:colors].any? do |c| + (existing = @color_scheme.colors_by_name[c[:name]]).nil? or existing.hex != c[:hex] or existing.opacity != c[:opacity] + end + end + + if new_version + old_version = ColorScheme.create( + name: @color_scheme.name, + enabled: false, + colors: @color_scheme.colors_hashes, + versioned_id: @color_scheme.id, + version: @color_scheme.version) + @color_scheme.version += 1 + end + + if @params[:colors] + @params[:colors].each do |c| + if existing = @color_scheme.colors_by_name[c[:name]] + existing.update_attributes(c) + end + end + end + + @color_scheme.save! + @color_scheme.clear_colors_cache + end + @color_scheme + end + + def revert + ColorScheme.transaction do + if prev = @color_scheme.previous_version + @color_scheme.version = prev.version + @color_scheme.colors.clear + prev.colors.update_all(color_scheme_id: @color_scheme.id) + prev.destroy + @color_scheme.save! + @color_scheme.clear_colors_cache + end + end + + @color_scheme + end + +end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 0628e3efd..89b96f933 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1445,6 +1445,21 @@ en: delete: "Delete" delete_confirm: "Delete this customization?" about: "Site Customization allow you to modify stylesheets and headers on the site. Choose or add one to start editing." + color: "Color" + opacity: "Opacity" + copy: "Copy" + css_html: + title: "CSS, HTML" + long_title: "CSS and HTML Customizations" + colors: + title: "Colors" + long_title: "Color Schemes" + about: "Color schemes allow you to modify the colors used on the site without writing CSS. Choose or add one to start." + new_name: "New Color Scheme" + copy_name_prefix: "Copy of" + delete_confirm: "Delete this color scheme?" + under_construction: "NOTE: Changes made here will do nothing! This feature is under construction!" + email: title: "Email" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 7a3d729f1..fbb452fc1 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1491,3 +1491,6 @@ en: message_to_blank: "message.to is blank" text_part_body_blank: "text_part.body is blank" body_blank: "body is blank" + + color_schemes: + base_theme_name: "Base" diff --git a/config/routes.rb b/config/routes.rb index 148a1519a..7349256d2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,7 +84,9 @@ Discourse::Application.routes.draw do resources :screened_urls, only: [:index] end - get "customize" => "site_customizations#index", constraints: AdminConstraint.new + get "customize" => "color_schemes#index", constraints: AdminConstraint.new + get "customize/css_html" => "site_customizations#index", constraints: AdminConstraint.new + get "customize/colors" => "color_schemes#index", constraints: AdminConstraint.new get "flags" => "flags#index" get "flags/:filter" => "flags#index" post "flags/agree/:id" => "flags#agree" @@ -93,6 +95,7 @@ Discourse::Application.routes.draw do resources :site_customizations, constraints: AdminConstraint.new resources :site_contents, constraints: AdminConstraint.new resources :site_content_types, constraints: AdminConstraint.new + resources :color_schemes, constraints: AdminConstraint.new get "version_check" => "versions#show" diff --git a/db/fixtures/701_color_schemes.rb b/db/fixtures/701_color_schemes.rb new file mode 100644 index 000000000..bd0c36605 --- /dev/null +++ b/db/fixtures/701_color_schemes.rb @@ -0,0 +1,14 @@ +ColorScheme.seed do |s| + s.id = 1 + s.name = I18n.t("color_schemes.base_theme_name") + s.enabled = false +end + +ColorSchemeColor::BASE_COLORS.each_with_index do |color, i| + ColorSchemeColor.seed do |c| + c.id = i+1 + c.name = color[0] + c.hex = color[1] + c.color_scheme_id = 1 + end +end diff --git a/db/migrate/20140416202746_create_color_schemes.rb b/db/migrate/20140416202746_create_color_schemes.rb new file mode 100644 index 000000000..e6491c0b9 --- /dev/null +++ b/db/migrate/20140416202746_create_color_schemes.rb @@ -0,0 +1,13 @@ +class CreateColorSchemes < ActiveRecord::Migration + def change + create_table :color_schemes do |t| + t.string :name, null: false + t.boolean :enabled, null: false, default: false + + t.integer :versioned_id + t.integer :version, null: false, default: 1 + + t.timestamps + end + end +end diff --git a/db/migrate/20140416202801_create_color_scheme_colors.rb b/db/migrate/20140416202801_create_color_scheme_colors.rb new file mode 100644 index 000000000..bed6d5fac --- /dev/null +++ b/db/migrate/20140416202801_create_color_scheme_colors.rb @@ -0,0 +1,14 @@ +class CreateColorSchemeColors < ActiveRecord::Migration + def change + create_table :color_scheme_colors do |t| + t.string :name, null: false + t.string :hex, null: false + t.integer :opacity, null: false, default: 100 + t.integer :color_scheme_id, null: false + + t.timestamps + end + + add_index :color_scheme_colors, [:color_scheme_id] + end +end diff --git a/spec/controllers/admin/color_schemes_controller_spec.rb b/spec/controllers/admin/color_schemes_controller_spec.rb new file mode 100644 index 000000000..70f2b42e4 --- /dev/null +++ b/spec/controllers/admin/color_schemes_controller_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe Admin::ColorSchemesController do + it "is a subclass of AdminController" do + (described_class < Admin::AdminController).should be_true + end + + context "while logged in as an admin" do + let!(:user) { log_in(:admin) } + let(:valid_params) { { color_scheme: { + name: 'Such Design', + enabled: true, + colors: [ + {name: '$primary_background_color', hex: 'FFBB00', opacity: '100'}, + {name: '$secondary_background_color', hex: '888888', opacity: '70'} + ] + } + } } + + describe "index" do + it "returns success" do + xhr :get, :index + response.should be_success + end + + it "returns JSON" do + Fabricate(:color_scheme) + xhr :get, :index + ::JSON.parse(response.body).should be_present + end + end + + describe "create" do + it "returns success" do + xhr :post, :create, valid_params + response.should be_success + end + + it "returns JSON" do + xhr :post, :create, valid_params + ::JSON.parse(response.body)['id'].should be_present + end + end + + describe "update" do + let(:existing) { Fabricate(:color_scheme) } + + it "returns success" do + ColorSchemeRevisor.expects(:revise).returns(existing) + xhr :put, :update, valid_params.merge(id: existing.id) + response.should be_success + end + + it "returns JSON" do + ColorSchemeRevisor.expects(:revise).returns(existing) + xhr :put, :update, valid_params.merge(id: existing.id) + ::JSON.parse(response.body)['id'].should be_present + end + end + + describe "destroy" do + let!(:existing) { Fabricate(:color_scheme) } + + it "returns success" do + expect { + xhr :delete, :destroy, id: existing.id + }.to change { ColorScheme.count }.by(-1) + response.should be_success + end + end + end +end diff --git a/spec/fabricators/color_scheme_color_fabricator.rb b/spec/fabricators/color_scheme_color_fabricator.rb new file mode 100644 index 000000000..2bfe62b93 --- /dev/null +++ b/spec/fabricators/color_scheme_color_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:color_scheme_color) do + color_scheme + name { sequence(:name) {|i| "$color_#{i}" } } + hex "333333" + opacity 100 +end diff --git a/spec/fabricators/color_scheme_fabricator.rb b/spec/fabricators/color_scheme_fabricator.rb new file mode 100644 index 000000000..6d4725eb1 --- /dev/null +++ b/spec/fabricators/color_scheme_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:color_scheme) do + name { sequence(:name) {|i| "Palette #{i}" } } + enabled false + color_scheme_colors(count: 2) { |attrs, i| Fabricate.build(:color_scheme_color, color_scheme: nil) } +end \ No newline at end of file diff --git a/spec/models/color_scheme_spec.rb b/spec/models/color_scheme_spec.rb new file mode 100644 index 000000000..88e59324e --- /dev/null +++ b/spec/models/color_scheme_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe ColorScheme do + + let(:valid_params) { {name: "Best Colors Evar", enabled: true, colors: valid_colors} } + let(:valid_colors) { [ + {name: '$primary_background_color', hex: 'FFBB00', opacity: '100'}, + {name: '$secondary_background_color', hex: '888888', opacity: '70'} + ]} + + describe "new" do + it "can take colors" do + c = described_class.new(valid_params) + c.colors.should have(valid_colors.size).colors + c.colors.first.should be_a(ColorSchemeColor) + expect { + c.save.should == true + }.to change { ColorSchemeColor.count }.by(valid_colors.size) + end + end + + describe "destroy" do + it "also destroys old versions" do + c1 = described_class.create(valid_params.merge(version: 2)) + c2 = described_class.create(valid_params.merge(versioned_id: c1.id, version: 1)) + other = described_class.create(valid_params) + expect { + c1.destroy + }.to change { described_class.count }.by(-2) + end + end + + describe "#enabled" do + it "returns the base color scheme when there is no enabled record" do + described_class.enabled.id.should == 1 + end + + it "returns the enabled color scheme" do + c = described_class.create(valid_params.merge(enabled: true)) + described_class.enabled.id.should == c.id + end + end +end diff --git a/spec/services/color_scheme_revisor_spec.rb b/spec/services/color_scheme_revisor_spec.rb new file mode 100644 index 000000000..66d97f8be --- /dev/null +++ b/spec/services/color_scheme_revisor_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +describe ColorSchemeRevisor do + + let(:color) { Fabricate.build(:color_scheme_color, hex: 'FFFFFF', color_scheme: nil) } + let(:color_scheme) { Fabricate(:color_scheme, enabled: false, created_at: 1.day.ago, updated_at: 1.day.ago, color_scheme_colors: [color]) } + let(:valid_params) { { name: color_scheme.name, enabled: color_scheme.enabled, colors: nil } } + + describe "revise" do + it "does nothing if there are no changes" do + expect { + described_class.revise(color_scheme, valid_params.merge(colors: nil)) + }.to_not change { color_scheme.reload.updated_at } + end + + it "can change the name" do + described_class.revise(color_scheme, valid_params.merge(name: "Changed Name")) + color_scheme.reload.name.should == "Changed Name" + end + + it "can enable and disable" do + described_class.revise(color_scheme, valid_params.merge(enabled: true)) + color_scheme.reload.should be_enabled + described_class.revise(color_scheme, valid_params.merge(enabled: false)) + color_scheme.reload.should_not be_enabled + end + + it "can change colors" do + described_class.revise(color_scheme, valid_params.merge(colors: [ + {name: color.name, hex: 'BEEF99', opacity: 99} + ])) + color_scheme.reload + color_scheme.colors.size.should == 1 + color_scheme.colors.first.hex.should == 'BEEF99' + color_scheme.colors.first.opacity.should == 99 + end + + it "disables other color scheme before enabling" do + prev_enabled = Fabricate(:color_scheme, enabled: true) + described_class.revise(color_scheme, valid_params.merge(enabled: true)) + prev_enabled.reload.enabled.should == false + color_scheme.reload.enabled.should == true + end + + describe "versions" do + it "doesn't create a new version if colors is not given" do + expect { + described_class.revise(color_scheme, valid_params.merge(name: "Changed Name")) + }.to_not change { color_scheme.reload.version } + end + + it "creates a new version if colors have changed" do + old_hex, old_opacity = color.hex, color.opacity + expect { + described_class.revise(color_scheme, valid_params.merge(colors: [ + {name: color.name, hex: 'BEEF99', opacity: 99} + ])) + }.to change { color_scheme.reload.version }.by(1) + old_version = ColorScheme.where(versioned_id: color_scheme.id, version: color_scheme.version - 1).first + old_version.should_not be_nil + old_version.colors.count.should == color_scheme.colors.count + old_version.colors_by_name[color.name].hex.should == old_hex + old_version.colors_by_name[color.name].opacity.should == old_opacity + color_scheme.colors_by_name[color.name].hex.should == 'BEEF99' + color_scheme.colors_by_name[color.name].opacity.should == 99 + end + + it "doesn't create a new version if colors have not changed" do + expect { + described_class.revise(color_scheme, valid_params.merge(colors: [ + {name: color.name, hex: color.hex, opacity: color.opacity} + ])) + }.to_not change { color_scheme.reload.version } + end + end + end + + describe "revert" do + context "when there are no previous versions" do + it "does nothing" do + expect { + described_class.revert(color_scheme).should == color_scheme + }.to_not change { color_scheme.reload.version } + end + end + + context 'when there are previous versions' do + let(:new_color_params) { {name: color.name, hex: 'BEEF99', opacity: 99} } + + before do + @prev_hex, @prev_opacity = color.hex, color.opacity + described_class.revise(color_scheme, valid_params.merge(colors: [ new_color_params ])) + end + + it "reverts the colors to the previous version" do + color_scheme.colors_by_name[new_color_params[:name]].hex.should == new_color_params[:hex] + expect { + described_class.revert(color_scheme) + }.to change { color_scheme.reload.version }.by(-1) + color_scheme.colors.size.should == 1 + color_scheme.colors.first.hex.should == @prev_hex + color_scheme.colors.first.opacity.should == @prev_opacity + color_scheme.colors_by_name[new_color_params[:name]].hex.should == @prev_hex + color_scheme.colors_by_name[new_color_params[:name]].opacity.should == @prev_opacity + end + + it "destroys the old version's record" do + expect { + described_class.revert(color_scheme) + }.to change { ColorScheme.count }.by(-1) + color_scheme.reload.previous_version.should be_nil + end + end + end + +end