diff --git a/app/assets/javascripts/wizard/components/scheme-preview.js.es6 b/app/assets/javascripts/wizard/components/scheme-preview.js.es6 new file mode 100644 index 000000000..484dafab5 --- /dev/null +++ b/app/assets/javascripts/wizard/components/scheme-preview.js.es6 @@ -0,0 +1,199 @@ +/*eslint no-bitwise:0 */ + +import { observes } from 'ember-addons/ember-computed-decorators'; + +const WIDTH = 400; +const HEIGHT = 220; +const LINE_HEIGHT = 12.0; + +const LOREM = ` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. +Nullam eget sem non elit tincidunt rhoncus. Fusce velit nisl, +porttitor sed nisl ac, consectetur interdum metus. Fusce in +consequat augue, vel facilisis felis. Nunc tellus elit, and +semper vitae orci nec, blandit pharetra enim. Aenean a ebus +posuere nunc. Maecenas ultrices viverra enim ac commodo +Vestibulum nec quam sit amet libero ultricies sollicitudin. +Nulla quis scelerisque sem, eget volutpat velit. Fusce eget +accumsan sapien, nec feugiat quam. Quisque non risus. +placerat lacus vitae, lacinia nisi. Sed metus arcu, iaculis +sit amet cursus nec, sodales at eros.`; + +function loadImage(src) { + const img = new Image(); + img.src = src; + + return new Ember.RSVP.Promise(resolve => img.onload = () => resolve(img)); +}; + +function parseColor(color) { + const m = color.match(/^#([0-9a-f]{6})$/i); + if (m) { + const c = m[1]; + return [ parseInt(c.substr(0,2),16), parseInt(c.substr(2,2),16), parseInt(c.substr(4,2),16) ]; + } + + return [0, 0, 0]; +} + +function brightness(color) { + return (color[0] * 0.299) + (color[1] * 0.587) + (color[2] * 0.114); +} + +function lighten(color, percent) { + return '#' + + ((0|(1<<8) + color[0] + (256 - color[0]) * percent / 100).toString(16)).substr(1) + + ((0|(1<<8) + color[1] + (256 - color[1]) * percent / 100).toString(16)).substr(1) + + ((0|(1<<8) + color[2] + (256 - color[2]) * percent / 100).toString(16)).substr(1); +} + +function chooseBrighter(primary, secondary) { + const primaryCol = parseColor(primary); + const secondaryCol = parseColor(secondary); + + return brightness(primaryCol) < brightness(secondaryCol) ? secondary : primary; +} + +function darkLightDiff(adjusted, comparison, lightness, darkness) { + const adjustedCol = parseColor(adjusted); + const comparisonCol = parseColor(comparison); + return lighten(adjustedCol, (brightness(adjustedCol) < brightness(comparisonCol)) ? + lightness : darkness); +} + +export default Ember.Component.extend({ + ctx: null, + width: WIDTH, + height: HEIGHT, + loaded: false, + logo: null, + + colorScheme: Ember.computed.alias('step.fieldsById.color_scheme.value'), + + didInsertElement() { + this._super(); + const c = this.$('canvas')[0]; + this.ctx = c.getContext("2d"); + + Ember.RSVP.Promise.all([loadImage('/images/wizard/discourse-small.png'), + loadImage('/images/wizard/trout.png')]).then(result => { + this.logo = result[0]; + this.avatar = result[1]; + this.loaded = true; + this.triggerRepaint(); + }); + }, + + @observes('colorScheme') + triggerRepaint() { + Ember.run.scheduleOnce('afterRender', this, 'repaint'); + }, + + repaint() { + if (!this.loaded) { return; } + + const { ctx } = this; + const headerHeight = HEIGHT * 0.15; + + const colorScheme = this.get('colorScheme'); + const options = this.get('step.fieldsById.color_scheme.options'); + const option = options.findProperty('id', colorScheme); + if (!option) { return; } + + const colors = option.data.colors; + if (!colors) { return; } + + ctx.fillStyle = colors.secondary; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + // Header area + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, WIDTH, headerHeight); + ctx.fillStyle = colors.header_background; + ctx.shadowColor = "rgba(0, 0, 0, 0.25)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 2; + ctx.fill(); + ctx.restore(); + + const margin = WIDTH * 0.02; + const avatarSize = HEIGHT * 0.1; + + // Logo + const headerMargin = headerHeight * 0.2; + const logoHeight = headerHeight - (headerMargin * 2); + const logoWidth = (logoHeight / this.logo.height) * this.logo.width; + ctx.drawImage(this.logo, headerMargin, headerMargin, logoWidth, logoHeight); + + // Top right menu + ctx.drawImage(this.avatar, WIDTH - avatarSize - headerMargin, headerMargin, avatarSize, avatarSize); + ctx.fillStyle = darkLightDiff(colors.primary, colors.secondary, 45, 55); + ctx.font = "0.75em FontAwesome"; + ctx.fillText("\uf0c9", WIDTH - (avatarSize * 2) - (headerMargin * 0.5), avatarSize); + ctx.fillText("\uf002", WIDTH - (avatarSize * 3) - (headerMargin * 0.5), avatarSize); + + // Draw a fake topic + ctx.drawImage(this.avatar, margin, headerHeight + (HEIGHT * 0.17), avatarSize, avatarSize); + + ctx.beginPath(); + ctx.fillStyle = colors.primary; + ctx.font = "bold 0.75em 'Arial'"; + ctx.fillText("Welcome to Discourse", margin, (HEIGHT * 0.25)); + + ctx.font = "0.5em 'Arial'"; + + let line = 0; + + const lines = LOREM.split("\n"); + for (let i=0; i<10; i++) { + line = (HEIGHT * 0.3) + (i * LINE_HEIGHT); + ctx.fillText(lines[i], margin + avatarSize + margin, line); + } + + // Reply Button + ctx.beginPath(); + ctx.rect(WIDTH * 0.57, line + LINE_HEIGHT, WIDTH * 0.1, HEIGHT * 0.07); + ctx.fillStyle = colors.tertiary; + ctx.fill(); + ctx.fillStyle = chooseBrighter(colors.primary, colors.secondary); + ctx.font = "8px 'Arial'"; + ctx.fillText("Reply", WIDTH * 0.595, line + (LINE_HEIGHT * 1.8)); + + // Icons + ctx.font = "0.5em FontAwesome"; + ctx.fillStyle = colors.love; + ctx.fillText("\uf004", WIDTH * 0.48, line + (LINE_HEIGHT * 1.8)); + ctx.fillStyle = darkLightDiff(colors.primary, colors.secondary, 65, 55); + ctx.fillText("\uf040", WIDTH * 0.525, line + (LINE_HEIGHT * 1.8)); + + // Draw Timeline + const timelineX = WIDTH * 0.8; + ctx.beginPath(); + ctx.strokeStyle = colors.tertiary; + ctx.lineWidth = 0.5; + ctx.moveTo(timelineX, HEIGHT * 0.3); + ctx.lineTo(timelineX, HEIGHT * 0.6); + ctx.stroke(); + + // Timeline + ctx.beginPath(); + ctx.strokeStyle = colors.tertiary; + ctx.lineWidth = 2; + ctx.moveTo(timelineX, HEIGHT * 0.3); + ctx.lineTo(timelineX, HEIGHT * 0.4); + ctx.stroke(); + + ctx.font = "Bold 0.5em Arial"; + ctx.fillStyle = colors.primary; + ctx.fillText("1 / 20", timelineX + margin, (HEIGHT * 0.3) + (margin * 1.5)); + + // draw border + ctx.beginPath(); + ctx.strokeStyle='rgba(0, 0, 0, 0.2)'; + ctx.rect(0, 0, WIDTH, HEIGHT); + ctx.stroke(); + } + +}); diff --git a/app/assets/javascripts/wizard/components/wizard-field.js.es6 b/app/assets/javascripts/wizard/components/wizard-field.js.es6 index cb4c67de9..67b65ee47 100644 --- a/app/assets/javascripts/wizard/components/wizard-field.js.es6 +++ b/app/assets/javascripts/wizard/components/wizard-field.js.es6 @@ -6,6 +6,9 @@ export default Ember.Component.extend({ @computed('field.id') inputClassName: id => `field-${Ember.String.dasherize(id)}`, - @computed('field.type') - inputComponentName: type => `wizard-field-${type}` + @computed('field.type', 'field.id') + inputComponentName(type, id) { + return (type === 'component') ? Ember.String.dasherize(id) : `wizard-field-${type}`; + } + }); diff --git a/app/assets/javascripts/wizard/models/step.js.es6 b/app/assets/javascripts/wizard/models/step.js.es6 index 5fa3b61be..a2ba11408 100644 --- a/app/assets/javascripts/wizard/models/step.js.es6 +++ b/app/assets/javascripts/wizard/models/step.js.es6 @@ -8,6 +8,13 @@ export default Ember.Object.extend(ValidState, { @computed('index') displayIndex: index => index + 1, + @computed('fields.[]') + fieldsById(fields) { + const lookup = {}; + fields.forEach(field => lookup[field.get('id')] = field); + return lookup; + }, + checkFields() { let allValid = true; this.get('fields').forEach(field => { diff --git a/app/assets/javascripts/wizard/templates/components/scheme-preview.hbs b/app/assets/javascripts/wizard/templates/components/scheme-preview.hbs new file mode 100644 index 000000000..a4950856a --- /dev/null +++ b/app/assets/javascripts/wizard/templates/components/scheme-preview.hbs @@ -0,0 +1,4 @@ +<div class='preview-area'> + <canvas width={{width}} height={{height}}> + </canvas> +</div> diff --git a/app/assets/javascripts/wizard/templates/components/wizard-field-dropdown.hbs b/app/assets/javascripts/wizard/templates/components/wizard-field-dropdown.hbs index 8339c5269..4f843aa3e 100644 --- a/app/assets/javascripts/wizard/templates/components/wizard-field-dropdown.hbs +++ b/app/assets/javascripts/wizard/templates/components/wizard-field-dropdown.hbs @@ -1 +1 @@ -{{combo-box value=field.value content=field.options nameProperty="label" width="400px"}} +{{combo-box class=inputClassName value=field.value content=field.options nameProperty="label" width="400px"}} diff --git a/app/assets/javascripts/wizard/templates/components/wizard-field.hbs b/app/assets/javascripts/wizard/templates/components/wizard-field.hbs index d757791f8..973630851 100644 --- a/app/assets/javascripts/wizard/templates/components/wizard-field.hbs +++ b/app/assets/javascripts/wizard/templates/components/wizard-field.hbs @@ -2,7 +2,7 @@ <span class='label-value'>{{field.label}}</span> <div class='input-area'> - {{component inputComponentName field=field inputClassName=inputClassName}} + {{component inputComponentName field=field step=step inputClassName=inputClassName}} </div> {{#if field.errorDescription}} diff --git a/app/assets/javascripts/wizard/templates/components/wizard-step.hbs b/app/assets/javascripts/wizard/templates/components/wizard-step.hbs index ff6a90120..52d174d06 100644 --- a/app/assets/javascripts/wizard/templates/components/wizard-step.hbs +++ b/app/assets/javascripts/wizard/templates/components/wizard-step.hbs @@ -8,7 +8,7 @@ {{#wizard-step-form step=step}} {{#each step.fields as |field|}} - {{wizard-field field=field}} + {{wizard-field field=field step=step}} {{/each}} {{/wizard-step-form}} diff --git a/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 b/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 index 345f61356..af59f65dd 100644 --- a/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 +++ b/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 @@ -8,7 +8,7 @@ test("Wizard starts", assert => { }); }); -test("Forum Name Step", assert => { +test("Going back and forth in steps", assert => { visit("/step/hello-world"); andThen(() => { assert.ok(exists('.wizard-step')); @@ -44,7 +44,10 @@ test("Forum Name Step", assert => { assert.ok(!exists('.wizard-field .field-error-description')); assert.ok(!exists('.wizard-step-title')); assert.ok(!exists('.wizard-step-description')); - assert.ok(exists('input.field-email'), "went to the next step"); + + assert.ok(exists('select.field-snack'), "went to the next step"); + assert.ok(exists('.preview-area'), "renders the component field"); + assert.ok(!exists('.wizard-btn.next')); assert.ok(exists('.wizard-btn.done'), 'last step shows a done button'); assert.ok(exists('.wizard-btn.back'), 'shows the back button'); diff --git a/app/assets/javascripts/wizard/test/test_helper.js b/app/assets/javascripts/wizard/test/test_helper.js index 17f51e38b..caf53122b 100644 --- a/app/assets/javascripts/wizard/test/test_helper.js +++ b/app/assets/javascripts/wizard/test/test_helper.js @@ -10,6 +10,7 @@ //= require ember-qunit //= require ember-shim //= require wizard-application +//= require wizard-vendor //= require helpers/assertions //= require_tree ./acceptance //= require_tree ./models diff --git a/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 b/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 index 6e0808fc6..546f61fd7 100644 --- a/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 +++ b/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 @@ -49,7 +49,10 @@ export default function() { { id: 'second-step', index: 1, - fields: [{ id: 'email', type: 'text', required: true }], + fields: [ + { id: 'snack', type: 'dropdown', required: true }, + { id: 'scheme-preview', type: 'component' } + ], previous: 'hello-world' }] } diff --git a/app/assets/stylesheets/wizard.scss b/app/assets/stylesheets/wizard.scss index dfffa573b..cfb048e5b 100644 --- a/app/assets/stylesheets/wizard.scss +++ b/app/assets/stylesheets/wizard.scss @@ -16,6 +16,9 @@ body.wizard { .select { width: 400px; } +.select2-results .select2-highlighted { + background: #ff9; +} .wizard-column { background-color: white; diff --git a/app/models/color_scheme.rb b/app/models/color_scheme.rb index 0c1867f45..11c022583 100644 --- a/app/models/color_scheme.rb +++ b/app/models/color_scheme.rb @@ -3,6 +3,32 @@ require_dependency 'distributed_cache' class ColorScheme < ActiveRecord::Base + def self.themes + base_with_hash = {} + base_colors.each do |name, color| + base_with_hash[name] = "##{color}" + end + + [ + { id: 'default', colors: base_with_hash }, + { + id: 'dark', + colors: { + "primary" => '#dddddd', + "secondary" => '#222222', + "tertiary" => '#0f82af', + "quaternary" => '#c14924', + "header_background" => '#111111', + "header_primary" => '#333333', + "highlight" => '#a87137', + "danger" => '#e45735', + "success" => '#1ca551', + "love" => '#fa6c8d' + } + } + ] + end + def self.hex_cache @hex_cache ||= DistributedCache.new("scheme_hex_for_name") end @@ -30,7 +56,7 @@ class ColorScheme < ActiveRecord::Base @mutex.synchronize do return @base_colors if @base_colors @base_colors = {} - read_colors_file.each do |line| + File.readlines(BASE_COLORS_FILE).each do |line| matches = /\$([\w]+):\s*#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})(?:[;]|\s)/.match(line.strip) @base_colors[matches[1]] = matches[2] if matches end @@ -38,10 +64,6 @@ class ColorScheme < ActiveRecord::Base @base_colors end - def self.read_colors_file - File.readlines(BASE_COLORS_FILE) - end - def self.enabled current_version.find_by(enabled: true) end @@ -114,7 +136,6 @@ class ColorScheme < ActiveRecord::Base DiscourseStylesheets.cache.clear end - def dump_hex_cache self.class.hex_cache.clear end diff --git a/app/serializers/wizard_field_serializer.rb b/app/serializers/wizard_field_serializer.rb index 07babfed0..7f69605ee 100644 --- a/app/serializers/wizard_field_serializer.rb +++ b/app/serializers/wizard_field_serializer.rb @@ -52,7 +52,17 @@ class WizardFieldSerializer < ApplicationSerializer def options object.options.map do |o| - {id: o, label: I18n.t("#{i18n_key}.options.#{o}")} + + result = {id: o, label: I18n.t("#{i18n_key}.options.#{o}")} + + data = object.option_data[o] + if data.present? + as_json = data.dup + as_json.delete(:id) + result[:data] = as_json + end + + result end end diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 3548ce278..c3be14d8a 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -3247,8 +3247,8 @@ en: color_scheme: label: "Color Scheme" options: - default: "Default Scheme" - dark: "Dark Scheme" + default: "Simple" + dark: "Dark" finished: title: "Your Discourse Forum is Ready!" diff --git a/db/migrate/20160906200439_add_via_wizard_to_color_schemes.rb b/db/migrate/20160906200439_add_via_wizard_to_color_schemes.rb new file mode 100644 index 000000000..40c69d416 --- /dev/null +++ b/db/migrate/20160906200439_add_via_wizard_to_color_schemes.rb @@ -0,0 +1,5 @@ +class AddViaWizardToColorSchemes < ActiveRecord::Migration + def change + add_column :color_schemes, :via_wizard, :boolean, default: false, null: false + end +end diff --git a/lib/wizard.rb b/lib/wizard.rb index a700f2457..7e8d46a00 100644 --- a/lib/wizard.rb +++ b/lib/wizard.rb @@ -44,8 +44,9 @@ class Wizard theme = wizard.create_step('colors') scheme = theme.add_field(id: 'color_scheme', type: 'dropdown', required: true) - scheme.add_option('default') - scheme.add_option('dark') + ColorScheme.themes.each {|t| scheme.add_option(t[:id], t) } + + theme.add_field(id: 'scheme_preview', type: 'component') wizard.append_step(theme) finished = wizard.create_step('finished') diff --git a/lib/wizard/field.rb b/lib/wizard/field.rb index 83b6763bd..70afc2df8 100644 --- a/lib/wizard/field.rb +++ b/lib/wizard/field.rb @@ -1,7 +1,7 @@ class Wizard class Field - attr_reader :id, :type, :required, :value, :options + attr_reader :id, :type, :required, :value, :options, :option_data attr_accessor :step def initialize(attrs) @@ -12,10 +12,12 @@ class Wizard @required = !!attrs[:required] @value = attrs[:value] @options = [] + @option_data = {} end - def add_option(id) + def add_option(id, data=nil) @options << id + @option_data[id] = data end end diff --git a/lib/wizard/step_updater.rb b/lib/wizard/step_updater.rb index aaf6208b4..543f55eff 100644 --- a/lib/wizard/step_updater.rb +++ b/lib/wizard/step_updater.rb @@ -23,6 +23,34 @@ class Wizard update_setting(:site_contact_username, fields, :site_contact_username) end + def update_colors(fields) + scheme_name = fields[:color_scheme] + + theme = ColorScheme.themes.find {|s| s[:id] == scheme_name } + + colors = [] + theme[:colors].each do |name, hex| + colors << {name: name, hex: hex[1..-1] } + end + + attrs = { + enabled: true, + name: I18n.t("wizard.step.colors.fields.color_scheme.options.#{scheme_name}"), + colors: colors + } + + scheme = ColorScheme.where(via_wizard: true).first + if scheme.present? + attrs[:colors] = colors + revisor = ColorSchemeRevisor.new(scheme, attrs) + revisor.revise + else + attrs[:via_wizard] = true + scheme = ColorScheme.new(attrs) + scheme.save! + end + end + def success? @errors.blank? end diff --git a/public/images/wizard/discourse-small.png b/public/images/wizard/discourse-small.png new file mode 100644 index 000000000..9fc9748d9 Binary files /dev/null and b/public/images/wizard/discourse-small.png differ diff --git a/public/images/wizard/trout.png b/public/images/wizard/trout.png new file mode 100644 index 000000000..5af72ee42 Binary files /dev/null and b/public/images/wizard/trout.png differ diff --git a/spec/components/step_updater_spec.rb b/spec/components/step_updater_spec.rb index df9826004..336c9fe33 100644 --- a/spec/components/step_updater_spec.rb +++ b/spec/components/step_updater_spec.rb @@ -21,7 +21,7 @@ describe Wizard::StepUpdater do contact_url: 'http://example.com/custom-contact-url', site_contact_username: user.username) - expect(updater.success?).to eq(true) + expect(updater).to be_success expect(SiteSetting.contact_email).to eq("eviltrout@example.com") expect(SiteSetting.contact_url).to eq("http://example.com/custom-contact-url") expect(SiteSetting.site_contact_username).to eq(user.username) @@ -30,9 +30,39 @@ describe Wizard::StepUpdater do it "doesn't update when there are errors" do updater.update(contact_email: 'not-an-email', site_contact_username: 'not-a-username') - expect(updater.success?).to eq(false) + expect(updater).to be_success expect(updater.errors).to be_present end end + context "colors step" do + let(:updater) { Wizard::StepUpdater.new(user, 'colors') } + + context "with an existing color scheme" do + let!(:color_scheme) { Fabricate(:color_scheme, name: 'existing', via_wizard: true) } + + it "updates the scheme" do + updater.update(color_scheme: 'dark') + expect(updater.success?).to eq(true) + + color_scheme.reload + expect(color_scheme).to be_enabled + + end + end + + context "without an existing scheme" do + + it "creates the scheme" do + updater.update(color_scheme: 'dark') + expect(updater.success?).to eq(true) + + color_scheme = ColorScheme.where(via_wizard: true).first + expect(color_scheme).to be_present + expect(color_scheme).to be_enabled + expect(color_scheme.colors).to be_present + end + end + end + end