Wizard: Server Side Validation + Finished Step

This commit is contained in:
Robin Ward 2016-08-31 13:35:49 -04:00
parent be1d74d207
commit 9f12b571ef
35 changed files with 260 additions and 62 deletions

View File

@ -64,10 +64,11 @@ export default Ember.Component.extend({
} }
const $elem = this.$(); const $elem = this.$();
const minimumResultsForSearch = this.capabilities.isIOS ? -1 : 5; const caps = this.capabilities;
const minimumResultsForSearch = (caps && caps.isIOS) ? -1 : 5;
$elem.select2({ $elem.select2({
formatResult: this.comboTemplate, minimumResultsForSearch, formatResult: this.comboTemplate, minimumResultsForSearch,
width: 'resolve', width: this.get('width') || 'resolve',
allowClear: true allowClear: true
}); });

View File

@ -0,0 +1,3 @@
import { htmlHelper } from 'discourse-common/lib/helpers';
export default htmlHelper((key, params) => I18n.t(key, params.hash));

View File

@ -1,4 +1,4 @@
import ComboboxView from 'discourse/components/combo-box'; import ComboboxView from 'discourse-common/components/combo-box';
import { categoryBadgeHTML } from 'discourse/helpers/category-link'; import { categoryBadgeHTML } from 'discourse/helpers/category-link';
import computed from 'ember-addons/ember-computed-decorators'; import computed from 'ember-addons/ember-computed-decorators';
import { observes, on } from 'ember-addons/ember-computed-decorators'; import { observes, on } from 'ember-addons/ember-computed-decorators';

View File

@ -1,5 +1,5 @@
import { iconHTML } from 'discourse-common/helpers/fa-icon'; import { iconHTML } from 'discourse-common/helpers/fa-icon';
import Combobox from 'discourse/components/combo-box'; import Combobox from 'discourse-common/components/combo-box';
import { on, observes } from 'ember-addons/ember-computed-decorators'; import { on, observes } from 'ember-addons/ember-computed-decorators';
export default Combobox.extend({ export default Combobox.extend({

View File

@ -1,3 +1,4 @@
//= require env
//= require jquery_include //= require jquery_include
//= require ember_include //= require ember_include
//= require loader //= require loader

View File

@ -60,7 +60,6 @@
//= require ./discourse/views/container //= require ./discourse/views/container
//= require ./discourse/views/modal-body //= require ./discourse/views/modal-body
//= require ./discourse/views/flag //= require ./discourse/views/flag
//= require ./discourse/components/combo-box
//= require ./discourse/components/edit-category-panel //= require ./discourse/components/edit-category-panel
//= require ./discourse/views/button //= require ./discourse/views/button
//= require ./discourse/components/dropdown-button //= require ./discourse/components/dropdown-button

View File

@ -1,5 +1,4 @@
//= require logster //= require logster
//= require ./env
//= require ./discourse-objects //= require ./discourse-objects
//= require probes.js //= require probes.js
@ -38,4 +37,3 @@
//= require virtual-dom //= require virtual-dom
//= require virtual-dom-amd //= require virtual-dom-amd
//= require highlight.js //= require highlight.js
//= require_tree ./discourse/ember

View File

@ -1,2 +1,2 @@
//= require env
//= require template_include.js //= require template_include.js
//= require select2.js

View File

@ -4,5 +4,8 @@ export default Ember.Component.extend({
classNameBindings: [':wizard-field', ':text-field', 'field.invalid'], classNameBindings: [':wizard-field', ':text-field', 'field.invalid'],
@computed('field.id') @computed('field.id')
inputClassName: id => `field-${Ember.String.dasherize(id)}` inputClassName: id => `field-${Ember.String.dasherize(id)}`,
@computed('field.type')
inputComponentName: type => `wizard-field-${type}`
}); });

View File

@ -12,6 +12,9 @@ export default Ember.Component.extend({
@computed('step.displayIndex', 'wizard.totalSteps') @computed('step.displayIndex', 'wizard.totalSteps')
showNextButton: (current, total) => current < total, showNextButton: (current, total) => current < total,
@computed('step.displayIndex', 'wizard.totalSteps')
showDoneButton: (current, total) => current === total,
@computed('step.index') @computed('step.index')
showBackButton: index => index > 0, showBackButton: index => index > 0,

View File

@ -8,6 +8,7 @@ export const States = {
export default { export default {
_validState: null, _validState: null,
errorDescription: null,
init() { init() {
this._super(); this._super();
@ -23,8 +24,14 @@ export default {
@computed('_validState') @computed('_validState')
unchecked: state => state === States.UNCHECKED, unchecked: state => state === States.UNCHECKED,
setValid(valid) { setValid(valid, description) {
this.set('_validState', valid ? States.VALID : States.INVALID); this.set('_validState', valid ? States.VALID : States.INVALID);
if (!valid && description && description.length) {
this.set('errorDescription', description);
} else {
this.set('errorDescription', null);
}
} }
}; };

View File

@ -1,7 +1,8 @@
export default Ember.Route.extend({ export default Ember.Route.extend({
model(params) { model(params) {
const allSteps = this.modelFor('application').steps; const allSteps = this.modelFor('application').steps;
return allSteps.findProperty('id', params.step_id); const step = allSteps.findProperty('id', params.step_id);
return step ? step : allSteps[0];
}, },
setupController(controller, step) { setupController(controller, step) {

View File

@ -0,0 +1 @@
{{combo-box value=field.value content=field.options nameProperty="label" width="400px"}}

View File

@ -0,0 +1 @@
{{input value=field.value class=inputClassName placeholder=field.placeholder}}

View File

@ -2,6 +2,14 @@
<span class='label-value'>{{field.label}}</span> <span class='label-value'>{{field.label}}</span>
<div class='input-area'> <div class='input-area'>
{{input value=field.value class=inputClassName placeholder=field.placeholder}} {{component inputComponentName field=field inputClassName=inputClassName}}
</div> </div>
{{#if field.errorDescription}}
<div class='field-error-description'>{{field.errorDescription}}</div>
{{/if}}
{{#if field.description}}
<div class='field-description'>{{field.description}}</div>
{{/if}}
</label> </label>

View File

@ -3,7 +3,7 @@
{{/if}} {{/if}}
{{#if step.description}} {{#if step.description}}
<p class='wizard-step-description'>{{step.description}}</p> <p class='wizard-step-description'>{{{step.description}}}</p>
{{/if}} {{/if}}
{{#wizard-step-form step=step}} {{#wizard-step-form step=step}}
@ -14,24 +14,33 @@
<div class='wizard-step-footer'> <div class='wizard-step-footer'>
<div class='wizard-progress'> <div class='wizard-progress'>
<div class='text'>{{i18n "wizard.step" current=step.displayIndex total=wizard.totalSteps}}</div> <div class='text'>{{bound-i18n "wizard.step" current=step.displayIndex total=wizard.totalSteps}}</div>
<div class='bar-container'> <div class='bar-container'>
<div class='bar-contents' style={{barStyle}}></div> <div class='bar-contents' style={{barStyle}}></div>
</div> </div>
</div> </div>
{{#if showBackButton}} <div class='wizard-buttons'>
<button class='wizard-btn back' {{action "backStep"}} disabled={{saving}}> {{#if showBackButton}}
{{fa-icon "chevron-left"}} <button class='wizard-btn back' {{action "backStep"}} disabled={{saving}}>
{{i18n "wizard.back"}} {{fa-icon "chevron-left"}}
</button> {{i18n "wizard.back"}}
{{/if}} </button>
{{/if}}
{{#if showNextButton}} {{#if showNextButton}}
<button class='wizard-btn next' {{action "nextStep"}} disabled={{saving}}> <button class='wizard-btn next' {{action "nextStep"}} disabled={{saving}}>
{{i18n "wizard.next"}} {{i18n "wizard.next"}}
{{fa-icon "chevron-right"}} {{fa-icon "chevron-right"}}
</button> </button>
{{/if}} {{/if}}
{{#if showDoneButton}}
<button class='wizard-btn done' {{action "finished"}} disabled={{saving}}>
{{fa-icon "check"}}
{{i18n "wizard.done"}}
</button>
{{/if}}
</div>
</div> </div>

View File

@ -18,7 +18,9 @@ test("Forum Name Step", assert => {
assert.ok(exists('.wizard-step-title')); assert.ok(exists('.wizard-step-title'));
assert.ok(exists('.wizard-step-description')); assert.ok(exists('.wizard-step-description'));
assert.ok(!exists('.invalid .field-full-name'), "don't show it as invalid until the user does something"); assert.ok(!exists('.invalid .field-full-name'), "don't show it as invalid until the user does something");
assert.ok(exists('.wizard-field .field-description'));
assert.ok(!exists('.wizard-btn.back')); assert.ok(!exists('.wizard-btn.back'));
assert.ok(!exists('.wizard-field .field-error-description'));
}); });
// invalid data // invalid data
@ -32,16 +34,19 @@ test("Forum Name Step", assert => {
click('.wizard-btn.next'); click('.wizard-btn.next');
andThen(() => { andThen(() => {
assert.ok(exists('.invalid .field-full-name')); assert.ok(exists('.invalid .field-full-name'));
assert.ok(exists('.wizard-field .field-error-description'));
}); });
// server validation ok // server validation ok
fillIn('input.field-full-name', "Evil Trout"); fillIn('input.field-full-name', "Evil Trout");
click('.wizard-btn.next'); click('.wizard-btn.next');
andThen(() => { andThen(() => {
assert.ok(!exists('.wizard-field .field-error-description'));
assert.ok(!exists('.wizard-step-title')); assert.ok(!exists('.wizard-step-title'));
assert.ok(!exists('.wizard-step-description')); assert.ok(!exists('.wizard-step-description'));
assert.ok(exists('input.field-email'), "went to the next step"); assert.ok(exists('input.field-email'), "went to the next step");
assert.ok(!exists('.wizard-btn.next')); 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'); assert.ok(exists('.wizard-btn.back'), 'shows the back button');
}); });

View File

@ -40,7 +40,10 @@ export default function() {
title: 'hello there', title: 'hello there',
index: 0, index: 0,
description: 'hello!', description: 'hello!',
fields: [{ id: 'full_name', type: 'text', required: true }], fields: [{ id: 'full_name',
type: 'text',
required: true,
description: "Your name" }],
next: 'second-step' next: 'second-step'
}, },
{ {

View File

@ -1,3 +1,8 @@
.select2-results .select2-highlighted {
background: dark-light-diff($highlight, $secondary, 50%, -80%);
color: $primary;
}
.category-combobox, .select2-drop { .category-combobox, .select2-drop {
.badge-category { .badge-category {

View File

@ -332,11 +332,6 @@ Version: @@ver@@ Timestamp: @@timestamp@@
.select2-results-dept-6 .select2-result-label { padding-left: 110px } .select2-results-dept-6 .select2-result-label { padding-left: 110px }
.select2-results-dept-7 .select2-result-label { padding-left: 120px } .select2-results-dept-7 .select2-result-label { padding-left: 120px }
.select2-results .select2-highlighted {
background: dark-light-diff($highlight, $secondary, 50%, -80%);
color: $primary;
}
.select2-results li em { .select2-results li em {
background: #feffde; background: #feffde;
font-style: normal; font-style: normal;

View File

@ -1,7 +1,8 @@
@import "vendor/normalize"; @import "vendor/normalize";
@import "vendor/font_awesome/font-awesome"; @import "vendor/font_awesome/font-awesome";
@import "vendor/select2";
body { body.wizard {
background-color: #fff; background-color: #fff;
background-image: url('/images/wizard/bubbles.png'); background-image: url('/images/wizard/bubbles.png');
background-repeat: repeat; background-repeat: repeat;
@ -12,6 +13,10 @@ body {
line-height: 1.4em; line-height: 1.4em;
} }
.select {
width: 400px;
}
.wizard-column { .wizard-column {
background-color: white; background-color: white;
box-shadow: 0 5px 10px rgba(0,0,0,0.2); box-shadow: 0 5px 10px rgba(0,0,0,0.2);
@ -77,10 +82,10 @@ body {
background-color: #6699ff; background-color: #6699ff;
color: white; color: white;
border: 0px; border: 0px;
float: right;
padding: 0.5em; padding: 0.5em;
outline: 0; outline: 0;
transition: background-color .3s; transition: background-color .3s;
margin-right: 0.5em;
&:hover { &:hover {
background-color: #80B3FF; background-color: #80B3FF;
@ -131,6 +136,25 @@ body {
} }
} }
button.wizard-btn:last-child {
margin-right: 0;
}
button.wizard-btn.done {
background-color: #33B333;
&:hover {
background-color: #4DCD4D;
}
&:active {
background-color: #66E666;
}
&:disabled {
background-color: #006700;
}
}
} }
.wizard-field { .wizard-field {
@ -142,6 +166,16 @@ body {
margin-top: 0.5em; margin-top: 0.5em;
} }
.field-error-description {
color: red;
font-weight: bold;
}
.field-description {
color: #999;
margin-top: 0.5em;
}
&.text-field { &.text-field {
input { input {
width: 100%; width: 100%;

View File

@ -9,7 +9,16 @@ class StepsController < ApplicationController
def update def update
updater = Wizard::StepUpdater.new(current_user, params[:id]) updater = Wizard::StepUpdater.new(current_user, params[:id])
updater.update(params[:fields]) updater.update(params[:fields])
render nothing: true
if updater.success?
render json: success_json
else
errors = []
updater.errors.messages.each do |field, msg|
errors << {field: field, description: msg.join }
end
render json: { errors: errors }, status: 422
end
end end
end end

View File

@ -1,6 +1,6 @@
class WizardFieldSerializer < ApplicationSerializer class WizardFieldSerializer < ApplicationSerializer
attributes :id, :type, :required, :value, :label, :placeholder attributes :id, :type, :required, :value, :label, :placeholder, :description, :options
def id def id
object.id object.id
@ -41,4 +41,23 @@ class WizardFieldSerializer < ApplicationSerializer
def include_placeholder? def include_placeholder?
placeholder.present? placeholder.present?
end end
def description
I18n.t("#{i18n_key}.description", default: '')
end
def include_description?
description.present?
end
def options
object.options.map do |o|
{id: o, label: I18n.t("#{i18n_key}.options.#{o}")}
end
end
def include_options?
object.options.present?
end
end end

View File

@ -1,8 +1,8 @@
<html> <html>
<head> <head>
<%= stylesheet_link_tag 'wizard' %> <%= stylesheet_link_tag 'wizard' %>
<%= script 'wizard-vendor' %>
<%= script 'ember_jquery' %> <%= script 'ember_jquery' %>
<%= script 'wizard-vendor' %>
<%= script 'wizard-application' %> <%= script 'wizard-application' %>
<%= script "locales/#{I18n.locale}" %> <%= script "locales/#{I18n.locale}" %>
<%= render partial: "common/special_font_face" %> <%= render partial: "common/special_font_face" %>
@ -12,7 +12,7 @@
<title><%= t 'wizard.title' %></title> <title><%= t 'wizard.title' %></title>
</head> </head>
<body> <body class='wizard'>
<div id='wizard-main'></div> <div id='wizard-main'></div>
<script> <script>

View File

@ -4,6 +4,7 @@
<title>QUnit Test Runner</title> <title>QUnit Test Runner</title>
<%= stylesheet_link_tag "qunit" %> <%= stylesheet_link_tag "qunit" %>
<%= stylesheet_link_tag "test_helper" %> <%= stylesheet_link_tag "test_helper" %>
<%= stylesheet_link_tag "wizard" %>
<%= javascript_include_tag "qunit" %> <%= javascript_include_tag "qunit" %>
<%= javascript_include_tag "wizard/test/test_helper" %> <%= javascript_include_tag "wizard/test/test_helper" %>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>

View File

@ -3227,6 +3227,7 @@ en:
wizard_js: wizard_js:
wizard: wizard:
done: "Done"
back: "Back" back: "Back"
next: "Next" next: "Next"
step: "Step %{current} of %{total}" step: "Step %{current} of %{total}"

View File

@ -3233,4 +3233,27 @@ en:
contact_email: contact_email:
label: "Contact E-mail" label: "Contact E-mail"
placeholder: "name@example.com" placeholder: "name@example.com"
description: "The key contact responsible for this site. This is used for critical notifications such as unhandled flags and on the \"About\" page for urgent matters."
contact_url:
label: "Contact URL"
placeholder: "http://www.example.com/contact-us"
description: "Will be displayed on your \"About\" page."
site_contact_username:
label: "Site Contact Username"
description: "All automated messages will be sent from this user."
colors:
title: "Choose a Color Scheme"
fields:
color_scheme:
label: "Color Scheme"
options:
default: "Default Scheme"
dark: "Dark Scheme"
finished:
title: "Your Discourse Forum is Ready!"
description: |
<p>If you ever feel like changing these settings, visit your admin section.</p>
<p>Have fun and enjoy your new community!</p>

View File

@ -27,7 +27,6 @@ class Wizard
step.previous = last_step step.previous = last_step
step.index = last_step.index + 1 step.index = last_step.index + 1
end end
end end
def self.build def self.build
@ -38,9 +37,20 @@ class Wizard
wizard.append_step(title) wizard.append_step(title)
contact = wizard.create_step('contact') contact = wizard.create_step('contact')
contact.add_field(id: 'contact_email', type: 'text', required: true) contact.add_field(id: 'contact_email', type: 'text', required: true, value: SiteSetting.contact_email)
contact.add_field(id: 'contact_url', type: 'text', value: SiteSetting.contact_url)
contact.add_field(id: 'site_contact_username', type: 'text', value: SiteSetting.site_contact_username)
wizard.append_step(contact) wizard.append_step(contact)
theme = wizard.create_step('colors')
scheme = theme.add_field(id: 'color_scheme', type: 'dropdown', required: true)
scheme.add_option('default')
scheme.add_option('dark')
wizard.append_step(theme)
finished = wizard.create_step('finished')
wizard.append_step(finished);
wizard wizard
end end
end end

View File

@ -1,6 +1,7 @@
class Wizard class Wizard
class Field class Field
attr_reader :id, :type, :required, :value
attr_reader :id, :type, :required, :value, :options
attr_accessor :step attr_accessor :step
def initialize(attrs) def initialize(attrs)
@ -10,6 +11,12 @@ class Wizard
@type = attrs[:type] @type = attrs[:type]
@required = !!attrs[:required] @required = !!attrs[:required]
@value = attrs[:value] @value = attrs[:value]
@options = []
end end
def add_option(id)
@options << id
end
end end
end end

View File

@ -1,22 +1,15 @@
class Wizard class Wizard
class StepUpdater class StepUpdater
include ActiveModel::Model
attr_accessor :errors
def initialize(current_user, id) def initialize(current_user, id)
@current_user = current_user @current_user = current_user
@id = id @id = id
@errors = []
end end
def update(fields) def update(fields)
updater_method = "update_#{@id.underscore}".to_sym updater_method = "update_#{@id.underscore}".to_sym
send(updater_method, fields.symbolize_keys) if respond_to?(updater_method)
if respond_to?(updater_method)
send(updater_method, fields.symbolize_keys)
else
raise Discourse::InvalidAccess.new
end
end end
def update_forum_title(fields) def update_forum_title(fields)
@ -24,6 +17,12 @@ class Wizard
update_setting(:site_description, fields, :site_description) update_setting(:site_description, fields, :site_description)
end end
def update_contact(fields)
update_setting(:contact_email, fields, :contact_email)
update_setting(:contact_url, fields, :contact_url)
update_setting(:site_contact_username, fields, :site_contact_username)
end
def success? def success?
@errors.blank? @errors.blank?
end end
@ -33,9 +32,10 @@ class Wizard
def update_setting(id, fields, field_id) def update_setting(id, fields, field_id)
value = fields[field_id] value = fields[field_id]
value.strip! if value.is_a?(String) value.strip! if value.is_a?(String)
SiteSetting.set_and_log(id, value, @current_user)
SiteSetting.set_and_log(id, value, @current_user) if SiteSetting.send(id) != value
rescue Discourse::InvalidParameters => e rescue Discourse::InvalidParameters => e
@errors << {field: field_id, description: e.message } errors.add(field_id, e.message)
end end
end end

View File

@ -4,11 +4,35 @@ require_dependency 'wizard/step_updater'
describe Wizard::StepUpdater do describe Wizard::StepUpdater do
let(:user) { Fabricate(:admin) } let(:user) { Fabricate(:admin) }
it "can update the forum title" do it "updates the forum title step" do
updater = Wizard::StepUpdater.new(user, 'forum_title') updater = Wizard::StepUpdater.new(user, 'forum_title')
updater.update(title: 'new forum title') updater.update(title: 'new forum title', site_description: 'neat place')
expect(updater.success?).to eq(true) expect(updater.success?).to eq(true)
expect(SiteSetting.title).to eq("new forum title") expect(SiteSetting.title).to eq("new forum title")
expect(SiteSetting.site_description).to eq("neat place")
end end
context "contact step" do
let(:updater) { Wizard::StepUpdater.new(user, 'contact') }
it "updates the fields correctly" do
updater.update(contact_email: 'eviltrout@example.com',
contact_url: 'http://example.com/custom-contact-url',
site_contact_username: user.username)
expect(updater.success?).to eq(true)
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)
end
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.errors).to be_present
end
end
end end

View File

@ -16,7 +16,6 @@ describe Wizard do
let(:step2) { wizard.create_step('second-step') } let(:step2) { wizard.create_step('second-step') }
it "adds the step correctly" do it "adds the step correctly" do
expect(step1.index).to be_blank expect(step1.index).to be_blank
wizard.append_step(step1) wizard.append_step(step1)

View File

@ -0,0 +1,24 @@
require 'rails_helper'
require 'wizard'
describe Wizard::Step do
let(:wizard) { Wizard.new }
let(:step) { wizard.create_step('test-step') }
it "supports fields and options" do
expect(step.fields).to be_empty
text = step.add_field(id: 'test', type: 'text')
expect(step.fields).to eq([text])
dropdown = step.add_field(id: 'snacks', type: 'dropdown')
dropdown.add_option(id: 'candy')
dropdown.add_option(id: 'nachos')
dropdown.add_option(id: 'pizza')
expect(step.fields).to eq([text, dropdown])
expect(dropdown.options.size).to eq(3)
end
end

View File

@ -3,6 +3,10 @@ require 'rails_helper'
describe ExtraLocalesController do describe ExtraLocalesController do
context 'show' do context 'show' do
before do
I18n.locale = :en
I18n.reload!
end
it "needs a valid bundle" do it "needs a valid bundle" do
get :show, bundle: 'made-up-bundle' get :show, bundle: 'made-up-bundle'

View File

@ -19,15 +19,15 @@ describe StepsController do
log_in(:admin) log_in(:admin)
end end
it "raises an error with an invalid id" do it "updates properly if you are staff" do
xhr :put, :update, id: 'made-up-id', fields: { forum_title: "updated title" } xhr :put, :update, id: 'contact', fields: { contact_email: "eviltrout@example.com" }
expect(response).to_not be_success expect(response).to be_success
expect(SiteSetting.contact_email).to eq("eviltrout@example.com")
end end
it "updates properly if you are staff" do it "returns errors if the field has them" do
xhr :put, :update, id: 'forum-title', fields: { title: "updated title" } xhr :put, :update, id: 'contact', fields: { contact_email: "not-an-email" }
expect(response).to be_success expect(response).to_not be_success
expect(SiteSetting.title).to eq("updated title")
end end
end end