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++) { for (var i=0; i<extras.length; i++) {
var messages = extras[i]; var messages = extras[i];
scope = origScope.split(sep); scope = origScope.split(sep);
if (scope[0] === 'js') {
scope.shift(); scope.shift();
}
while (messages && scope.length > 0) { while (messages && scope.length > 0) {
currentScope = scope.shift(); 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/resolver
//= require wizard/router //= require wizard/router
//= require wizard/wizard //= require wizard/wizard
//= require_tree ./wizard/templates //= require_tree ./wizard/templates
//= require_tree ./wizard/components
//= require_tree ./wizard/models
//= require_tree ./wizard/routes //= require_tree ./wizard/routes
//= require_tree ./wizard/controllers //= 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({ export default Ember.Controller.extend({
wizard: null,
step: 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({ export default Ember.DefaultResolver.extend({
resolveRoute(parsedName) { resolveRoute: customResolve,
return resolveType(parsedName) || this._super(parsedName); resolveController: customResolve,
}, resolveComponent: customResolve,
resolveController(parsedName) {
return resolveType(parsedName) || this._super(parsedName);
},
resolveTemplate(parsedName) { resolveTemplate(parsedName) {
const templates = Ember.TEMPLATES; 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({ export default Ember.Route.extend({
beforeModel() { 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({ export default Ember.Route.extend({
model(params) { model(params) {
return { const allSteps = this.modelFor('application').steps;
id: params.step_id, return allSteps.findProperty('id', params.step_id);
title: "You're a wizard harry!"
};
}, },
setupController(controller, model) { setupController(controller, step) {
controller.set('step', model); controller.setProperties({
step, wizard: this.modelFor('application')
});
} }
}); });

View File

@ -1,7 +1,8 @@
<div class='wizard-column'> <div class='wizard-column'>
<div class='wizard-column-contents'> <div class='wizard-column-contents'>
Discourse!
{{outlet}} {{outlet}}
</div> </div>
<div class='wizard-footer'>
<img src="/images/wizard/discourse.png" class="logo">
</div>
</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'> {{wizard-step step=step wizard=wizard goNext="goNext" goBack="goBack"}}
{{step.title}}
</div>

View File

@ -1,10 +1,54 @@
module("Acceptance: wizard"); module("Acceptance: wizard");
test("Wizard loads", assert => { test("Wizard starts", assert => {
visit("/"); visit("/");
andThen(() => { andThen(() => {
assert.ok(exists('.wizard-column-contents')); 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 env
//= require jquery.debug //= require jquery.debug
@ -8,9 +8,16 @@
//= require ember.debug //= require ember.debug
//= require ember-template-compiler //= require ember-template-compiler
//= require ember-qunit //= require ember-qunit
//= require ember-shim
//= require wizard-application //= require wizard-application
//= require helpers/assertions //= require helpers/assertions
//= require_tree ./acceptance //= 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 // Trick JSHint into allow document.write
var d = document; var d = document;
@ -23,15 +30,23 @@ if (window.Logster) {
window.Logster = { enabled: false }; 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({ var wizard = require('wizard/wizard').default.create({
rootElement: '#ember-testing' rootElement: '#ember-testing'
}); });
wizard.setupForTesting(); wizard.setupForTesting();
wizard.injectTestHelpers(); wizard.injectTestHelpers();
wizard.start();
QUnit.testDone(function() {
wizard.reset();
});
Object.keys(requirejs.entries).forEach(function(entry) { Object.keys(requirejs.entries).forEach(function(entry) {
if ((/\-test/).test(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({ export default Ember.Application.extend({
rootElement: '#wizard-main', rootElement: '#wizard-main',
Resolver, 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);
}
});
}
}); });

View File

@ -2,18 +2,21 @@
@import "vendor/font_awesome/font-awesome"; @import "vendor/font_awesome/font-awesome";
body { body {
background-color: rgb(231,238,247); background-color: #fff;
background-image: url('/images/wizard/bubbles.png'); background-image: url('/images/wizard/bubbles.png');
background-repeat: repeat; background-repeat: repeat;
background-position: left top; background-position: left top;
color: #444;
line-height: 1.4em;
} }
.wizard-column { .wizard-column {
background-color: white; background-color: white;
border-radius: 2px;
box-shadow: 0 5px 10px rgba(0,0,0,0.2); box-shadow: 0 5px 10px rgba(0,0,0,0.2);
box-sizing: border-box; box-sizing: border-box;
margin: 0.75rem auto; margin: 1.5em auto;
padding: 0; padding: 0;
max-width: 700px; max-width: 700px;
min-width: 280px; min-width: 280px;
@ -21,11 +24,144 @@ body {
border: 1px solid #ccc; border: 1px solid #ccc;
.wizard-column-contents { .wizard-column-contents {
padding: 1em; padding: 1.2em;
h1 { 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;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
class WizardSerializer < ApplicationSerializer
attributes :start
has_many :steps, serializer: WizardStepSerializer, embed: :objects
def start
object.start.id
end
end

View File

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

View File

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

View File

@ -7,6 +7,7 @@
<%= 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 %>
<script src="/extra-locales/wizard"></script>
</head> </head>
<body> <body>
<div id="qunit"></div> <div id="qunit"></div>

View File

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

View File

@ -3225,5 +3225,9 @@ en:
add: "Add" add: "Add"
filter: "Search (URL or External URL)" filter: "Search (URL or External URL)"
# WARNING! Keys added here will be in the admin_js section. wizard_js:
# Keys that don't belong in admin should be placed earlier in the file. wizard:
back: "Back"
next: "Next"
step: "Step %{current} of %{total}"

View File

@ -3212,3 +3212,25 @@ en:
staff_tag_disallowed: "The tag \"%{tag}\" may only be applied by staff." 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." staff_tag_remove_disallowed: "The tag \"%{tag}\" may only be removed by staff."
rss_by_tag: "Topics tagged %{tag}" 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"

View File

@ -53,10 +53,10 @@ Discourse::Application.routes.draw do
resources :forums resources :forums
get "srv/status" => "forums#status" get "srv/status" => "forums#status"
namespace :wizard, constraints: StaffConstraint.new do get "wizard" => "wizard#index"
get "" => "wizard#index" get "wizard/qunit" => "wizard#qunit"
get "qunit" => "wizard#qunit" get 'wizard/steps' => 'steps#index'
end put 'wizard/steps/:id' => "steps#update"
namespace :admin, constraints: StaffConstraint.new do namespace :admin, constraints: StaffConstraint.new do
get "" => "admin#index" get "" => "admin#index"

46
lib/wizard.rb Normal file
View File

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

15
lib/wizard/field.rb Normal file
View File

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

18
lib/wizard/step.rb Normal file
View File

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

View File

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

View File

@ -3,7 +3,6 @@ require 'cache'
describe Gaps do describe Gaps do
it 'returns no gaps for empty data' do it 'returns no gaps for empty data' do
expect(Gaps.new(nil, nil)).to be_blank expect(Gaps.new(nil, nil)).to be_blank
end end

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
require 'rails_helper' require 'rails_helper'
describe Wizard::WizardController do describe WizardController do
context 'index' do context 'index' do
render_views render_views
@ -20,6 +20,14 @@ describe Wizard::WizardController do
xhr :get, :index xhr :get, :index
expect(response).to be_success expect(response).to be_success
end 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
end end

View File

@ -29,7 +29,6 @@ describe "i18n integrity checks" do
client = YAML.load_file("#{Rails.root}/config/locales/client.#{locale}.yml") client = YAML.load_file("#{Rails.root}/config/locales/client.#{locale}.yml")
expect(client.count).to eq(1) expect(client.count).to eq(1)
expect(client[locale]).not_to eq(nil) 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]["js"]).not_to eq(nil)
expect(client[locale]["admin_js"]).not_to eq(nil) expect(client[locale]["admin_js"]).not_to eq(nil)
end end