Wizard: Step 1

This commit is contained in:
Robin Ward
2016-08-25 13:14:56 -04:00
parent 0471ad393c
commit 3a4615c205
50 changed files with 1103 additions and 80 deletions

View File

@@ -98,7 +98,9 @@ function checkExtras(origScope, sep, extras) {
for (var i=0; i<extras.length; i++) {
var messages = extras[i];
scope = origScope.split(sep);
scope.shift();
if (scope[0] === 'js') {
scope.shift();
}
while (messages && scope.length > 0) {
currentScope = scope.shift();

View File

@@ -1,6 +1,18 @@
//= require_tree ./ember-addons/utils
//= require ./ember-addons/decorator-alias
//= require ./ember-addons/macro-alias
//= require ./ember-addons/ember-computed-decorators
//= require discourse/lib/raw-handlebars
//= require discourse/lib/helpers
//= require wizard/resolver
//= require wizard/router
//= require wizard/wizard
//= require_tree ./wizard/templates
//= require_tree ./wizard/components
//= require_tree ./wizard/models
//= require_tree ./wizard/routes
//= require_tree ./wizard/controllers
//= require_tree ./wizard/lib
//= require_tree ./wizard/mixins
//= require_tree ./wizard/helpers
//= require_tree ./wizard/initializers

View File

@@ -0,0 +1,8 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNameBindings: [':wizard-field', ':text-field', 'field.invalid'],
@computed('field.id')
inputClassName: id => `field-${Ember.String.dasherize(id)}`
});

View File

@@ -0,0 +1,8 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNameBindings: [':wizard-step-form', 'customStepClass'],
@computed('step.id')
customStepClass: stepId => `wizard-step-${stepId}`,
});

View File

@@ -0,0 +1,85 @@
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNames: ['wizard-step'],
saving: null,
didInsertElement() {
this._super();
this.autoFocus();
},
@computed('step.displayIndex', 'wizard.totalSteps')
showNextButton: (current, total) => current < total,
@computed('step.index')
showBackButton: index => index > 0,
@observes('step.id')
_stepChanged() {
this.set('saving', false);
this.autoFocus();
},
keyPress(key) {
if (key.keyCode === 13) {
this.send('nextStep');
}
},
@computed('step.displayIndex', 'wizard.totalSteps')
barStyle(displayIndex, totalSteps) {
const ratio = parseFloat(displayIndex) / parseFloat(totalSteps) * 100;
return Ember.String.htmlSafe(`width: ${ratio}%`);
},
autoFocus() {
Ember.run.scheduleOnce('afterRender', () => {
const $invalid = $('.wizard-field.invalid:eq(0) input');
if ($invalid.length) {
return $invalid.focus();
}
$('input:eq(0)').focus();
});
},
saveStep() {
const step = this.get('step');
step.save()
.then(() => this.sendAction('goNext'))
.catch(response => {
const errors = response.responseJSON.errors;
if (errors && errors.length) {
errors.forEach(err => {
step.fieldError(err.field, err.description);
});
}
});
},
actions: {
backStep() {
if (this.get('saving')) { return; }
this.sendAction('goBack');
},
nextStep() {
if (this.get('saving')) { return; }
const step = this.get('step');
step.checkFields();
if (step.get('valid')) {
this.set('saving', true);
step.save()
.then(() => this.sendAction('goNext'))
.catch(() => null) // we can swallow because the form is already marked as invalid
.finally(() => this.set('saving', false));
} else {
this.autoFocus();
}
}
}
});

View File

@@ -1,3 +1,13 @@
export default Ember.Controller.extend({
wizard: null,
step: null,
actions: {
goNext() {
this.transitionToRoute('step', this.get('step.next'));
},
goBack() {
this.transitionToRoute('step', this.get('step.previous'));
},
}
});

View File

@@ -0,0 +1,3 @@
import { registerUnbound } from 'discourse/lib/helpers';
registerUnbound('i18n', (key, params) => I18n.t(key, params));

View File

@@ -0,0 +1,11 @@
export default {
name: 'load-helpers',
initialize() {
Object.keys(requirejs.entries).forEach(entry => {
if ((/\/helpers\//).test(entry)) {
require(entry, null, null, true);
}
});
}
};

View File

@@ -0,0 +1,18 @@
let token;
export function ajax(args) {
if (!token) {
token = $('meta[name="csrf-token"]').attr('content');
}
return new Ember.RSVP.Promise((resolve, reject) => {
args.headers = {
'X-CSRF-Token': token
};
args.success = data => Ember.run(null, resolve, data);
args.error = xhr => Ember.run(null, reject, xhr);
Ember.$.ajax(args);
});
}

View File

@@ -0,0 +1,30 @@
import computed from 'ember-addons/ember-computed-decorators';
export const States = {
UNCHECKED: 0,
INVALID: 1,
VALID: 2
};
export default {
_validState: null,
init() {
this._super();
this.set('_validState', States.UNCHECKED);
},
@computed('_validState')
valid: state => state === States.VALID,
@computed('_validState')
invalid: state => state === States.INVALID,
@computed('_validState')
unchecked: state => state === States.UNCHECKED,
setValid(valid) {
this.set('_validState', valid ? States.VALID : States.INVALID);
}
};

View File

@@ -0,0 +1,41 @@
import computed from 'ember-addons/ember-computed-decorators';
import ValidState from 'wizard/mixins/valid-state';
import { ajax } from 'wizard/lib/ajax';
export default Ember.Object.extend(ValidState, {
id: null,
@computed('index')
displayIndex: index => index + 1,
checkFields() {
let allValid = true;
this.get('fields').forEach(field => {
field.check();
allValid = allValid && field.get('valid');
});
this.setValid(allValid);
},
fieldError(id, description) {
const field = this.get('fields').findProperty('id', id);
if (field) {
field.setValid(false, description);
}
},
save() {
const fields = {};
this.get('fields').forEach(f => fields[f.id] = f.value);
return ajax({
url: `/wizard/steps/${this.get('id')}`,
type: 'PUT',
data: { fields }
}).catch(response => {
response.responseJSON.errors.forEach(err => this.fieldError(err.field, err.description));
throw response;
});
}
});

View File

@@ -0,0 +1,17 @@
import ValidState from 'wizard/mixins/valid-state';
export default Ember.Object.extend(ValidState, {
id: null,
type: null,
value: null,
required: null,
check() {
if (!this.get('required')) {
return this.setValid(true);
}
const val = this.get('value');
this.setValid(val && val.length > 0);
}
});

View File

@@ -0,0 +1,23 @@
import Step from 'wizard/models/step';
import WizardField from 'wizard/models/wizard-field';
import { ajax } from 'wizard/lib/ajax';
import computed from 'ember-addons/ember-computed-decorators';
const Wizard = Ember.Object.extend({
@computed('steps.length')
totalSteps: length => length
});
export function findWizard() {
return ajax({ url: '/wizard.json' }).then(response => {
const wizard = response.wizard;
wizard.steps = wizard.steps.map(step => {
const stepObj = Step.create(step);
stepObj.fields = stepObj.fields.map(f => WizardField.create(f));
return stepObj;
});
return Wizard.create(wizard);
});
}

View File

@@ -8,15 +8,15 @@ function resolveType(parsedName) {
}
}
function customResolve(parsedName) {
return resolveType(parsedName) || this._super(parsedName);
}
export default Ember.DefaultResolver.extend({
resolveRoute(parsedName) {
return resolveType(parsedName) || this._super(parsedName);
},
resolveController(parsedName) {
return resolveType(parsedName) || this._super(parsedName);
},
resolveRoute: customResolve,
resolveController: customResolve,
resolveComponent: customResolve,
resolveTemplate(parsedName) {
const templates = Ember.TEMPLATES;

View File

@@ -0,0 +1,7 @@
import { findWizard } from 'wizard/models/wizard';
export default Ember.Route.extend({
model() {
return findWizard();
}
});

View File

@@ -1,5 +1,6 @@
export default Ember.Route.extend({
beforeModel() {
this.replaceWith('step', 'welcome');
const appModel = this.modelFor('application');
this.replaceWith('step', appModel.start);
}
});

View File

@@ -1,12 +1,12 @@
export default Ember.Route.extend({
model(params) {
return {
id: params.step_id,
title: "You're a wizard harry!"
};
const allSteps = this.modelFor('application').steps;
return allSteps.findProperty('id', params.step_id);
},
setupController(controller, model) {
controller.set('step', model);
setupController(controller, step) {
controller.setProperties({
step, wizard: this.modelFor('application')
});
}
});

View File

@@ -1,7 +1,8 @@
<div class='wizard-column'>
<div class='wizard-column-contents'>
Discourse!
{{outlet}}
</div>
<div class='wizard-footer'>
<img src="/images/wizard/discourse.png" class="logo">
</div>
</div>

View File

@@ -0,0 +1,7 @@
<label>
<span class='label-value'>{{field.label}}</span>
<div class='input-area'>
{{input value=field.value class=inputClassName placeholder=field.placeholder}}
</div>
</label>

View File

@@ -0,0 +1,37 @@
{{#if step.title}}
<h1 class='wizard-step-title'>{{step.title}}</h1>
{{/if}}
{{#if step.description}}
<p class='wizard-step-description'>{{step.description}}</p>
{{/if}}
{{#wizard-step-form step=step}}
{{#each step.fields as |field|}}
{{wizard-field field=field}}
{{/each}}
{{/wizard-step-form}}
<div class='wizard-step-footer'>
<div class='wizard-progress'>
<div class='text'>{{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}}>
<i class='fa fa-chevron-left'></i>
{{i18n "wizard.back"}}
</button>
{{/if}}
{{#if showNextButton}}
<button class='wizard-btn next' {{action "nextStep"}} disabled={{saving}}>
{{i18n "wizard.next"}}
<i class='fa fa-chevron-right'></i>
</button>
{{/if}}
</div>

View File

@@ -1,3 +1 @@
<div class='wizard-step'>
{{step.title}}
</div>
{{wizard-step step=step wizard=wizard goNext="goNext" goBack="goBack"}}

View File

@@ -1,10 +1,54 @@
module("Acceptance: wizard");
test("Wizard loads", assert => {
test("Wizard starts", assert => {
visit("/");
andThen(() => {
assert.ok(exists('.wizard-column-contents'));
assert.equal(currentPath(), 'steps');
assert.equal(currentPath(), 'step');
});
});
test("Forum Name Step", assert => {
visit("/step/hello-world");
andThen(() => {
assert.ok(exists('.wizard-step'));
assert.ok(exists('.wizard-step-hello-world'), 'it adds a class for the step id');
assert.ok(exists('.wizard-progress'));
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-btn.back'));
});
// invalid data
click('.wizard-btn.next');
andThen(() => {
assert.ok(exists('.invalid .field-full-name'));
});
// server validation fail
fillIn('input.field-full-name', "Server Fail");
click('.wizard-btn.next');
andThen(() => {
assert.ok(exists('.invalid .field-full-name'));
});
// server validation ok
fillIn('input.field-full-name', "Evil Trout");
click('.wizard-btn.next');
andThen(() => {
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.back'), 'shows the back button');
});
click('.wizard-btn.back');
andThen(() => {
assert.ok(exists('.wizard-step-title'));
assert.ok(exists('.wizard-btn.next'));
assert.ok(!exists('.wizard-prev'));
});
});

View File

@@ -0,0 +1,34 @@
import WizardField from 'wizard/models/wizard-field';
module("model:wizard-field");
test('basic state', assert => {
const w = WizardField.create({ type: 'text' });
assert.ok(w.get('unchecked'));
assert.ok(!w.get('valid'));
assert.ok(!w.get('invalid'));
});
test('text - required - validation', assert => {
const w = WizardField.create({ type: 'text', required: true });
assert.ok(w.get('unchecked'));
w.check();
assert.ok(!w.get('unchecked'));
assert.ok(!w.get('valid'));
assert.ok(w.get('invalid'));
w.set('value', 'a value');
w.check();
assert.ok(!w.get('unchecked'));
assert.ok(w.get('valid'));
assert.ok(!w.get('invalid'));
});
test('text - optional - validation', assert => {
const w = WizardField.create({ type: 'text' });
assert.ok(w.get('unchecked'));
w.check();
assert.ok(w.get('valid'));
});

View File

@@ -1,4 +1,4 @@
/*global document, sinon, QUnit, Logster */
/*global document, sinon, Logster, QUnit */
//= require env
//= require jquery.debug
@@ -8,9 +8,16 @@
//= require ember.debug
//= require ember-template-compiler
//= require ember-qunit
//= require ember-shim
//= require wizard-application
//= require helpers/assertions
//= require_tree ./acceptance
//= require_tree ./models
//= require locales/en
//= require fake_xml_http_request
//= require route-recognizer
//= require pretender
//= require ./wizard-pretender
// Trick JSHint into allow document.write
var d = document;
@@ -23,15 +30,23 @@ if (window.Logster) {
window.Logster = { enabled: false };
}
var createPretendServer = require('wizard/test/wizard-pretender', null, null, false).default;
var server;
QUnit.testStart(function() {
server = createPretendServer();
});
QUnit.testDone(function() {
server.shutdown();
});
var wizard = require('wizard/wizard').default.create({
rootElement: '#ember-testing'
});
wizard.setupForTesting();
wizard.injectTestHelpers();
QUnit.testDone(function() {
wizard.reset();
});
wizard.start();
Object.keys(requirejs.entries).forEach(function(entry) {
if ((/\-test/).test(entry)) {

View File

@@ -0,0 +1,83 @@
// TODO: This file has some copied and pasted functions from `create-pretender` - would be good
// to centralize that code at some point.
function parsePostData(query) {
const result = {};
query.split("&").forEach(function(part) {
const item = part.split("=");
const firstSeg = decodeURIComponent(item[0]);
const m = /^([^\[]+)\[([^\]]+)\]/.exec(firstSeg);
const val = decodeURIComponent(item[1]).replace(/\+/g, ' ');
if (m) {
result[m[1]] = result[m[1]] || {};
result[m[1]][m[2]] = val;
} else {
result[firstSeg] = val;
}
});
return result;
}
function response(code, obj) {
if (typeof code === "object") {
obj = code;
code = 200;
}
return [code, {"Content-Type": "application/json"}, obj];
}
export default function() {
const server = new Pretender(function() {
this.get('/wizard.json', () => {
return response(200, {
wizard: {
start: 'hello-world',
steps: [{
id: 'hello-world',
title: 'hello there',
index: 0,
description: 'hello!',
fields: [{ id: 'full_name', type: 'text', required: true }],
next: 'second-step'
},
{
id: 'second-step',
index: 1,
fields: [{ id: 'email', type: 'text', required: true }],
previous: 'hello-world'
}]
}
});
});
this.put('/wizard/steps/:id', request => {
const body = parsePostData(request.requestBody);
if (body.fields.full_name === "Server Fail") {
return response(422, {
errors: [{ field: "full_name", description: "Invalid name" }]
});
} else {
return response(200, { success: true });
}
});
});
server.prepareBody = function(body){
if (body && typeof body === "object") {
return JSON.stringify(body);
}
return body;
};
server.unhandledRequest = function(verb, path) {
const error = 'Unhandled request in test environment: ' + path + ' (' + verb + ')';
window.console.error(error);
throw error;
};
return server;
}

View File

@@ -4,5 +4,15 @@ import Router from 'wizard/router';
export default Ember.Application.extend({
rootElement: '#wizard-main',
Resolver,
Router
Router,
start() {
Object.keys(requirejs._eak_seen).forEach(key => {
if (/\/initializers\//.test(key)) {
const module = require(key, null, null, true);
if (!module) { throw new Error(key + ' must export an initializer.'); }
this.instanceInitializer(module.default);
}
});
}
});