Wizard: Step 1
This commit is contained in:
parent
0471ad393c
commit
3a4615c205
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)}`
|
||||
});
|
|
@ -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}`,
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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'));
|
||||
},
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
import { registerUnbound } from 'discourse/lib/helpers';
|
||||
|
||||
registerUnbound('i18n', (key, params) => I18n.t(key, params));
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
};
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { findWizard } from 'wizard/models/wizard';
|
||||
|
||||
export default Ember.Route.extend({
|
||||
model() {
|
||||
return findWizard();
|
||||
}
|
||||
});
|
|
@ -1,5 +1,6 @@
|
|||
export default Ember.Route.extend({
|
||||
beforeModel() {
|
||||
this.replaceWith('step', 'welcome');
|
||||
const appModel = this.modelFor('application');
|
||||
this.replaceWith('step', appModel.start);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,3 +1 @@
|
|||
<div class='wizard-step'>
|
||||
{{step.title}}
|
||||
</div>
|
||||
{{wizard-step step=step wizard=wizard goNext="goNext" goBack="goBack"}}
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'));
|
||||
});
|
|
@ -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)) {
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -2,18 +2,21 @@
|
|||
@import "vendor/font_awesome/font-awesome";
|
||||
|
||||
body {
|
||||
background-color: rgb(231,238,247);
|
||||
background-color: #fff;
|
||||
background-image: url('/images/wizard/bubbles.png');
|
||||
background-repeat: repeat;
|
||||
background-position: left top;
|
||||
|
||||
color: #444;
|
||||
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.wizard-column {
|
||||
background-color: white;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,0.2);
|
||||
box-sizing: border-box;
|
||||
margin: 0.75rem auto;
|
||||
margin: 1.5em auto;
|
||||
padding: 0;
|
||||
max-width: 700px;
|
||||
min-width: 280px;
|
||||
|
@ -21,11 +24,144 @@ body {
|
|||
border: 1px solid #ccc;
|
||||
|
||||
.wizard-column-contents {
|
||||
padding: 1em;
|
||||
padding: 1.2em;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-step-description {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
.wizard-footer {
|
||||
border-top: 1px solid #ccc;
|
||||
background-color: #eee;
|
||||
padding: 0.5em;
|
||||
img.logo {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.wizard-progress {
|
||||
.text {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.bar-container {
|
||||
border: 1px solid #ff6600;
|
||||
border-radius: 3px;
|
||||
height: 0.5em;
|
||||
width: 15em;
|
||||
|
||||
.bar-contents {
|
||||
background-color: #ff6600;
|
||||
width: 0;
|
||||
height: 0.5em;
|
||||
transition: width .3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.wizard-step-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
button.wizard-btn {
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
|
||||
font-size: 1.0em;
|
||||
background-color: #6699ff;
|
||||
color: white;
|
||||
border: 0px;
|
||||
float: right;
|
||||
padding: 0.5em;
|
||||
outline: 0;
|
||||
transition: background-color .3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #80B3FF;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #4D80E6;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #000167;
|
||||
}
|
||||
|
||||
i.fa-chevron-right {
|
||||
margin-left: 0.25em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
i.fa-chevron-left {
|
||||
margin-right: 0.25em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
button.wizard-btn.next {
|
||||
min-width: 70px;
|
||||
|
||||
i.fa-chevron-right {
|
||||
margin-left: 0.25em;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
button.wizard-btn.back {
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .4);
|
||||
|
||||
&:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.wizard-field {
|
||||
label .label-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
&.text-field {
|
||||
input {
|
||||
width: 100%;
|
||||
font-size: 1.2em;
|
||||
padding: 6px;
|
||||
border: 1px solid #ccc;
|
||||
transition: border-color .5s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.invalid {
|
||||
input {
|
||||
padding: 3px;
|
||||
border: 4px solid red;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
require_dependency 'wizard'
|
||||
require_dependency 'wizard/step_updater'
|
||||
|
||||
class StepsController < ApplicationController
|
||||
|
||||
before_filter :ensure_logged_in
|
||||
before_filter :ensure_staff
|
||||
|
||||
def update
|
||||
updater = Wizard::StepUpdater.new(current_user, params[:id])
|
||||
updater.update(params[:fields])
|
||||
render nothing: true
|
||||
end
|
||||
|
||||
end
|
|
@ -1,16 +0,0 @@
|
|||
class Wizard::WizardController < ApplicationController
|
||||
|
||||
before_filter :ensure_logged_in
|
||||
before_filter :ensure_staff
|
||||
|
||||
skip_before_filter :check_xhr, :preload_json
|
||||
|
||||
layout false
|
||||
|
||||
def index
|
||||
end
|
||||
|
||||
def qunit
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,25 @@
|
|||
require_dependency 'wizard'
|
||||
|
||||
class WizardController < ApplicationController
|
||||
|
||||
before_filter :ensure_logged_in
|
||||
before_filter :ensure_staff
|
||||
|
||||
skip_before_filter :check_xhr, :preload_json
|
||||
|
||||
layout false
|
||||
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
wizard = Wizard.build
|
||||
render_serialized(wizard, WizardSerializer)
|
||||
end
|
||||
format.html {}
|
||||
end
|
||||
end
|
||||
|
||||
def qunit
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,44 @@
|
|||
class WizardFieldSerializer < ApplicationSerializer
|
||||
|
||||
attributes :id, :type, :required, :value, :label, :placeholder
|
||||
|
||||
def id
|
||||
object.id
|
||||
end
|
||||
|
||||
def type
|
||||
object.type
|
||||
end
|
||||
|
||||
def required
|
||||
object.required
|
||||
end
|
||||
|
||||
def value
|
||||
object.value
|
||||
end
|
||||
|
||||
def include_value?
|
||||
object.value.present?
|
||||
end
|
||||
|
||||
def i18n_key
|
||||
@i18n_key ||= "wizard.step.#{object.step.id}.fields.#{object.id}".underscore
|
||||
end
|
||||
|
||||
def label
|
||||
I18n.t("#{i18n_key}.label", default: '')
|
||||
end
|
||||
|
||||
def include_label?
|
||||
label.present?
|
||||
end
|
||||
|
||||
def placeholder
|
||||
I18n.t("#{i18n_key}.placeholder", default: '')
|
||||
end
|
||||
|
||||
def include_placeholder?
|
||||
placeholder.present?
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
class WizardSerializer < ApplicationSerializer
|
||||
attributes :start
|
||||
|
||||
has_many :steps, serializer: WizardStepSerializer, embed: :objects
|
||||
|
||||
def start
|
||||
object.start.id
|
||||
end
|
||||
end
|
|
@ -0,0 +1,50 @@
|
|||
class WizardStepSerializer < ApplicationSerializer
|
||||
|
||||
attributes :id, :next, :previous, :description, :title, :index
|
||||
has_many :fields, serializer: WizardFieldSerializer, embed: :objects
|
||||
|
||||
def id
|
||||
object.id
|
||||
end
|
||||
|
||||
def index
|
||||
object.index
|
||||
end
|
||||
|
||||
def next
|
||||
object.next.id if object.next.present?
|
||||
end
|
||||
|
||||
def include_next?
|
||||
object.next.present?
|
||||
end
|
||||
|
||||
def previous
|
||||
object.previous.id if object.previous.present?
|
||||
end
|
||||
|
||||
def include_previous?
|
||||
object.previous.present?
|
||||
end
|
||||
|
||||
def i18n_key
|
||||
@i18n_key ||= "wizard.step.#{object.id}".underscore
|
||||
end
|
||||
|
||||
def description
|
||||
I18n.t("#{i18n_key}.description", default: '')
|
||||
end
|
||||
|
||||
def include_description?
|
||||
description.present?
|
||||
end
|
||||
|
||||
def title
|
||||
I18n.t("#{i18n_key}.title", default: '')
|
||||
end
|
||||
|
||||
def include_title?
|
||||
title.present?
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,25 @@
|
|||
<html>
|
||||
<head>
|
||||
<%= stylesheet_link_tag 'wizard' %>
|
||||
<%= script 'wizard-vendor' %>
|
||||
<%= script 'ember_jquery' %>
|
||||
<%= script 'wizard-application' %>
|
||||
<%= script "locales/#{I18n.locale}" %>
|
||||
<%= render partial: "common/special_font_face" %>
|
||||
<script src="/extra-locales/wizard"></script>
|
||||
<%= csrf_meta_tags %>
|
||||
|
||||
<title><%= t 'wizard.title' %></title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id='wizard-main'></div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var wizard = require('wizard/wizard').default.create();
|
||||
wizard.start();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -7,6 +7,7 @@
|
|||
<%= javascript_include_tag "qunit" %>
|
||||
<%= javascript_include_tag "wizard/test/test_helper" %>
|
||||
<%= csrf_meta_tags %>
|
||||
<script src="/extra-locales/wizard"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="qunit"></div>
|
|
@ -1,21 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<%= stylesheet_link_tag 'wizard' %>
|
||||
<%= javascript_include_tag 'wizard-vendor' %>
|
||||
<%= javascript_include_tag 'ember_jquery' %>
|
||||
<%= javascript_include_tag 'wizard-application' %>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id='wizard-main'>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
require('wizard/wizard').default.create();
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -3225,5 +3225,9 @@ en:
|
|||
add: "Add"
|
||||
filter: "Search (URL or External URL)"
|
||||
|
||||
# WARNING! Keys added here will be in the admin_js section.
|
||||
# Keys that don't belong in admin should be placed earlier in the file.
|
||||
wizard_js:
|
||||
wizard:
|
||||
back: "Back"
|
||||
next: "Next"
|
||||
step: "Step %{current} of %{total}"
|
||||
|
||||
|
|
|
@ -3212,3 +3212,25 @@ en:
|
|||
staff_tag_disallowed: "The tag \"%{tag}\" may only be applied by staff."
|
||||
staff_tag_remove_disallowed: "The tag \"%{tag}\" may only be removed by staff."
|
||||
rss_by_tag: "Topics tagged %{tag}"
|
||||
|
||||
wizard:
|
||||
title: "Discourse Setup Wizard"
|
||||
step:
|
||||
forum_title:
|
||||
title: "Welcome to your Discourse forum!"
|
||||
description: "There are a few things you'll need to configure before inviting more people to the party. Don't worry, you can come back and change these settings at any time, so don't overthink it."
|
||||
|
||||
fields:
|
||||
title:
|
||||
label: "Enter a title for your forum"
|
||||
placeholder: "Jane's Hangout"
|
||||
site_description:
|
||||
label: "Describe your forum in one sentence"
|
||||
placeholder: "A place for Jane and her friends to discuss cool stuff"
|
||||
contact:
|
||||
title: "Don't be a Stranger"
|
||||
fields:
|
||||
contact_email:
|
||||
label: "Contact E-mail"
|
||||
placeholder: "name@example.com"
|
||||
|
||||
|
|
|
@ -53,10 +53,10 @@ Discourse::Application.routes.draw do
|
|||
resources :forums
|
||||
get "srv/status" => "forums#status"
|
||||
|
||||
namespace :wizard, constraints: StaffConstraint.new do
|
||||
get "" => "wizard#index"
|
||||
get "qunit" => "wizard#qunit"
|
||||
end
|
||||
get "wizard" => "wizard#index"
|
||||
get "wizard/qunit" => "wizard#qunit"
|
||||
get 'wizard/steps' => 'steps#index'
|
||||
put 'wizard/steps/:id' => "steps#update"
|
||||
|
||||
namespace :admin, constraints: StaffConstraint.new do
|
||||
get "" => "admin#index"
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
require_dependency 'wizard/step'
|
||||
require_dependency 'wizard/field'
|
||||
|
||||
class Wizard
|
||||
attr_reader :start
|
||||
attr_reader :steps
|
||||
|
||||
def initialize
|
||||
@steps = []
|
||||
end
|
||||
|
||||
def create_step(args)
|
||||
Step.new(args)
|
||||
end
|
||||
|
||||
def append_step(step)
|
||||
last_step = @steps.last
|
||||
|
||||
@steps << step
|
||||
|
||||
# If it's the first step
|
||||
if @steps.size == 1
|
||||
@start = step
|
||||
step.index = 0
|
||||
elsif last_step.present?
|
||||
last_step.next = step
|
||||
step.previous = last_step
|
||||
step.index = last_step.index + 1
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def self.build
|
||||
wizard = Wizard.new
|
||||
title = wizard.create_step('forum-title')
|
||||
title.add_field(id: 'title', type: 'text', required: true, value: SiteSetting.title)
|
||||
title.add_field(id: 'site_description', type: 'text', required: true, value: SiteSetting.site_description)
|
||||
wizard.append_step(title)
|
||||
|
||||
contact = wizard.create_step('contact')
|
||||
contact.add_field(id: 'contact_email', type: 'text', required: true)
|
||||
wizard.append_step(contact)
|
||||
|
||||
wizard
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
class Wizard
|
||||
class Field
|
||||
attr_reader :id, :type, :required, :value
|
||||
attr_accessor :step
|
||||
|
||||
def initialize(attrs)
|
||||
attrs = attrs || {}
|
||||
|
||||
@id = attrs[:id]
|
||||
@type = attrs[:type]
|
||||
@required = !!attrs[:required]
|
||||
@value = attrs[:value]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,18 @@
|
|||
class Wizard
|
||||
class Step
|
||||
attr_reader :id
|
||||
attr_accessor :index, :fields, :next, :previous
|
||||
|
||||
def initialize(id)
|
||||
@id = id
|
||||
@fields = []
|
||||
end
|
||||
|
||||
def add_field(attrs)
|
||||
field = Field.new(attrs)
|
||||
field.step = self
|
||||
@fields << field
|
||||
field
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,42 @@
|
|||
class Wizard
|
||||
class StepUpdater
|
||||
|
||||
attr_accessor :errors
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
def update_forum_title(fields)
|
||||
update_setting(:title, fields, :title)
|
||||
update_setting(:site_description, fields, :site_description)
|
||||
end
|
||||
|
||||
def success?
|
||||
@errors.blank?
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
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)
|
||||
rescue Discourse::InvalidParameters => e
|
||||
@errors << {field: field_id, description: e.message }
|
||||
end
|
||||
|
||||
end
|
||||
end
|
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
|
@ -3,7 +3,6 @@ require 'cache'
|
|||
|
||||
describe Gaps do
|
||||
|
||||
|
||||
it 'returns no gaps for empty data' do
|
||||
expect(Gaps.new(nil, nil)).to be_blank
|
||||
end
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
require 'rails_helper'
|
||||
require_dependency 'wizard/step_updater'
|
||||
|
||||
describe Wizard::StepUpdater do
|
||||
let(:user) { Fabricate(:admin) }
|
||||
|
||||
it "can update the forum title" do
|
||||
updater = Wizard::StepUpdater.new(user, 'forum_title')
|
||||
updater.update(title: 'new forum title')
|
||||
|
||||
expect(updater.success?).to eq(true)
|
||||
expect(SiteSetting.title).to eq("new forum title")
|
||||
end
|
||||
end
|
|
@ -0,0 +1,48 @@
|
|||
require 'rails_helper'
|
||||
require 'wizard'
|
||||
|
||||
describe Wizard do
|
||||
|
||||
let(:wizard) { Wizard.new }
|
||||
|
||||
it "has default values" do
|
||||
expect(wizard.start).to be_blank
|
||||
expect(wizard.steps).to be_empty
|
||||
end
|
||||
|
||||
describe "append_step" do
|
||||
|
||||
let(:step1) { wizard.create_step('first-step') }
|
||||
let(:step2) { wizard.create_step('second-step') }
|
||||
|
||||
it "adds the step correctly" do
|
||||
|
||||
expect(step1.index).to be_blank
|
||||
|
||||
wizard.append_step(step1)
|
||||
expect(wizard.steps.size).to eq(1)
|
||||
expect(wizard.start).to eq(step1)
|
||||
expect(step1.next).to be_blank
|
||||
expect(step1.previous).to be_blank
|
||||
expect(step1.index).to eq(0)
|
||||
|
||||
expect(step1.fields).to be_empty
|
||||
field = step1.add_field(id: 'test', type: 'text')
|
||||
expect(step1.fields).to eq([field])
|
||||
end
|
||||
|
||||
it "sequences multiple steps" do
|
||||
wizard.append_step(step1)
|
||||
wizard.append_step(step2)
|
||||
|
||||
expect(wizard.steps.size).to eq(2)
|
||||
expect(wizard.start).to eq(step1)
|
||||
expect(step1.next).to eq(step2)
|
||||
expect(step1.previous).to be_blank
|
||||
expect(step2.previous).to eq(step1)
|
||||
expect(step1.index).to eq(0)
|
||||
expect(step2.index).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
|
@ -0,0 +1,35 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe StepsController do
|
||||
|
||||
it 'needs you to be logged in' do
|
||||
expect {
|
||||
xhr :put, :update, id: 'made-up-id', fields: { forum_title: "updated title" }
|
||||
}.to raise_error(Discourse::NotLoggedIn)
|
||||
end
|
||||
|
||||
it "raises an error if you aren't an admin" do
|
||||
log_in
|
||||
xhr :put, :update, id: 'made-up-id', fields: { forum_title: "updated title" }
|
||||
expect(response).to be_forbidden
|
||||
end
|
||||
|
||||
context "as an admin" do
|
||||
before 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
|
||||
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")
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe Wizard::WizardController do
|
||||
describe WizardController do
|
||||
|
||||
context 'index' do
|
||||
render_views
|
||||
|
@ -20,6 +20,14 @@ describe Wizard::WizardController do
|
|||
xhr :get, :index
|
||||
expect(response).to be_success
|
||||
end
|
||||
|
||||
it "returns JSON when the mime type is appropriate" do
|
||||
log_in(:admin)
|
||||
xhr :get, :index, format: 'json'
|
||||
expect(response).to be_success
|
||||
expect(::JSON.parse(response.body).has_key?('wizard')).to eq(true)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
|
@ -29,7 +29,6 @@ describe "i18n integrity checks" do
|
|||
client = YAML.load_file("#{Rails.root}/config/locales/client.#{locale}.yml")
|
||||
expect(client.count).to eq(1)
|
||||
expect(client[locale]).not_to eq(nil)
|
||||
expect(client[locale].count).to eq(2)
|
||||
expect(client[locale]["js"]).not_to eq(nil)
|
||||
expect(client[locale]["admin_js"]).not_to eq(nil)
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue