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 minimumResultsForSearch = this.capabilities.isIOS ? -1 : 5;
const caps = this.capabilities;
const minimumResultsForSearch = (caps && caps.isIOS) ? -1 : 5;
$elem.select2({
formatResult: this.comboTemplate, minimumResultsForSearch,
width: 'resolve',
width: this.get('width') || 'resolve',
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 computed 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 Combobox from 'discourse/components/combo-box';
import Combobox from 'discourse-common/components/combo-box';
import { on, observes } from 'ember-addons/ember-computed-decorators';
export default Combobox.extend({

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
//= require env
//= 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'],
@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')
showNextButton: (current, total) => current < total,
@computed('step.displayIndex', 'wizard.totalSteps')
showDoneButton: (current, total) => current === total,
@computed('step.index')
showBackButton: index => index > 0,

View File

@ -8,6 +8,7 @@ export const States = {
export default {
_validState: null,
errorDescription: null,
init() {
this._super();
@ -23,8 +24,14 @@ export default {
@computed('_validState')
unchecked: state => state === States.UNCHECKED,
setValid(valid) {
setValid(valid, description) {
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({
model(params) {
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) {

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>
<div class='input-area'>
{{input value=field.value class=inputClassName placeholder=field.placeholder}}
{{component inputComponentName field=field inputClassName=inputClassName}}
</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>

View File

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

View File

@ -18,7 +18,9 @@ test("Forum Name Step", assert => {
assert.ok(exists('.wizard-step-title'));
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('.wizard-field .field-description'));
assert.ok(!exists('.wizard-btn.back'));
assert.ok(!exists('.wizard-field .field-error-description'));
});
// invalid data
@ -32,16 +34,19 @@ test("Forum Name Step", assert => {
click('.wizard-btn.next');
andThen(() => {
assert.ok(exists('.invalid .field-full-name'));
assert.ok(exists('.wizard-field .field-error-description'));
});
// server validation ok
fillIn('input.field-full-name', "Evil Trout");
click('.wizard-btn.next');
andThen(() => {
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('.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

@ -40,7 +40,10 @@ export default function() {
title: 'hello there',
index: 0,
description: 'hello!',
fields: [{ id: 'full_name', type: 'text', required: true }],
fields: [{ id: 'full_name',
type: 'text',
required: true,
description: "Your name" }],
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 {
.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-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 {
background: #feffde;
font-style: normal;

View File

@ -1,7 +1,8 @@
@import "vendor/normalize";
@import "vendor/font_awesome/font-awesome";
@import "vendor/select2";
body {
body.wizard {
background-color: #fff;
background-image: url('/images/wizard/bubbles.png');
background-repeat: repeat;
@ -12,6 +13,10 @@ body {
line-height: 1.4em;
}
.select {
width: 400px;
}
.wizard-column {
background-color: white;
box-shadow: 0 5px 10px rgba(0,0,0,0.2);
@ -77,10 +82,10 @@ body {
background-color: #6699ff;
color: white;
border: 0px;
float: right;
padding: 0.5em;
outline: 0;
transition: background-color .3s;
margin-right: 0.5em;
&:hover {
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 {
@ -142,6 +166,16 @@ body {
margin-top: 0.5em;
}
.field-error-description {
color: red;
font-weight: bold;
}
.field-description {
color: #999;
margin-top: 0.5em;
}
&.text-field {
input {
width: 100%;

View File

@ -9,7 +9,16 @@ class StepsController < ApplicationController
def update
updater = Wizard::StepUpdater.new(current_user, params[:id])
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

View File

@ -1,6 +1,6 @@
class WizardFieldSerializer < ApplicationSerializer
attributes :id, :type, :required, :value, :label, :placeholder
attributes :id, :type, :required, :value, :label, :placeholder, :description, :options
def id
object.id
@ -41,4 +41,23 @@ class WizardFieldSerializer < ApplicationSerializer
def include_placeholder?
placeholder.present?
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

View File

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

View File

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

View File

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

View File

@ -3233,4 +3233,27 @@ en:
contact_email:
label: "Contact E-mail"
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.index = last_step.index + 1
end
end
def self.build
@ -38,9 +37,20 @@ class Wizard
wizard.append_step(title)
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)
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
end
end

View File

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

View File

@ -1,22 +1,15 @@
class Wizard
class StepUpdater
attr_accessor :errors
include ActiveModel::Model
def initialize(current_user, id)
@current_user = current_user
@id = id
@errors = []
end
def update(fields)
updater_method = "update_#{@id.underscore}".to_sym
if respond_to?(updater_method)
send(updater_method, fields.symbolize_keys)
else
raise Discourse::InvalidAccess.new
end
send(updater_method, fields.symbolize_keys) if respond_to?(updater_method)
end
def update_forum_title(fields)
@ -24,6 +17,12 @@ class Wizard
update_setting(:site_description, fields, :site_description)
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?
@errors.blank?
end
@ -33,9 +32,10 @@ class Wizard
def update_setting(id, fields, field_id)
value = fields[field_id]
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
@errors << {field: field_id, description: e.message }
errors.add(field_id, e.message)
end
end

View File

@ -4,11 +4,35 @@ require_dependency 'wizard/step_updater'
describe Wizard::StepUpdater do
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.update(title: 'new forum title')
updater.update(title: 'new forum title', site_description: 'neat place')
expect(updater.success?).to eq(true)
expect(SiteSetting.title).to eq("new forum title")
expect(SiteSetting.site_description).to eq("neat place")
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

View File

@ -16,7 +16,6 @@ describe Wizard do
let(:step2) { wizard.create_step('second-step') }
it "adds the step correctly" do
expect(step1.index).to be_blank
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
context 'show' do
before do
I18n.locale = :en
I18n.reload!
end
it "needs a valid bundle" do
get :show, bundle: 'made-up-bundle'

View File

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