Wizard - Color Scheme Step

This commit is contained in:
Robin Ward 2016-09-02 11:42:14 -04:00
parent 9f12b571ef
commit 3f6e3b9aff
21 changed files with 343 additions and 23 deletions

View File

@ -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();
}
});

View File

@ -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}`;
}
});

View File

@ -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 => {

View File

@ -0,0 +1,4 @@
<div class='preview-area'>
<canvas width={{width}} height={{height}}>
</canvas>
</div>

View File

@ -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"}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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');

View File

@ -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

View File

@ -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'
}]
}

View File

@ -16,6 +16,9 @@ body.wizard {
.select {
width: 400px;
}
.select2-results .select2-highlighted {
background: #ff9;
}
.wizard-column {
background-color: white;

View File

@ -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

View File

@ -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

View File

@ -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!"

View File

@ -0,0 +1,5 @@
class AddViaWizardToColorSchemes < ActiveRecord::Migration
def change
add_column :color_schemes, :via_wizard, :boolean, default: false, null: false
end
end

View File

@ -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')

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@ -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