diff --git a/app/assets/javascripts/locales/i18n.js b/app/assets/javascripts/locales/i18n.js index fa7e9f95263..97b1a1196c7 100644 --- a/app/assets/javascripts/locales/i18n.js +++ b/app/assets/javascripts/locales/i18n.js @@ -98,7 +98,9 @@ function checkExtras(origScope, sep, extras) { for (var i=0; i 0) { currentScope = scope.shift(); diff --git a/app/assets/javascripts/wizard-application.js b/app/assets/javascripts/wizard-application.js index 1d77f9dfa57..5547ae0f3da 100644 --- a/app/assets/javascripts/wizard-application.js +++ b/app/assets/javascripts/wizard-application.js @@ -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 diff --git a/app/assets/javascripts/wizard/components/wizard-field.js.es6 b/app/assets/javascripts/wizard/components/wizard-field.js.es6 new file mode 100644 index 00000000000..4d2500322d1 --- /dev/null +++ b/app/assets/javascripts/wizard/components/wizard-field.js.es6 @@ -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)}` +}); diff --git a/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 b/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 new file mode 100644 index 00000000000..c5946608c8b --- /dev/null +++ b/app/assets/javascripts/wizard/components/wizard-step-form.js.es6 @@ -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}`, +}); diff --git a/app/assets/javascripts/wizard/components/wizard-step.js.es6 b/app/assets/javascripts/wizard/components/wizard-step.js.es6 new file mode 100644 index 00000000000..a39d50e3b24 --- /dev/null +++ b/app/assets/javascripts/wizard/components/wizard-step.js.es6 @@ -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(); + } + } + } +}); diff --git a/app/assets/javascripts/wizard/controllers/step.js.es6 b/app/assets/javascripts/wizard/controllers/step.js.es6 index bc7c7b20bd5..e9bd4bce64d 100644 --- a/app/assets/javascripts/wizard/controllers/step.js.es6 +++ b/app/assets/javascripts/wizard/controllers/step.js.es6 @@ -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')); + }, + } }); diff --git a/app/assets/javascripts/wizard/helpers/i18n.js.es6 b/app/assets/javascripts/wizard/helpers/i18n.js.es6 new file mode 100644 index 00000000000..21d53160f98 --- /dev/null +++ b/app/assets/javascripts/wizard/helpers/i18n.js.es6 @@ -0,0 +1,3 @@ +import { registerUnbound } from 'discourse/lib/helpers'; + +registerUnbound('i18n', (key, params) => I18n.t(key, params)); diff --git a/app/assets/javascripts/wizard/initializers/load-helpers.js.es6 b/app/assets/javascripts/wizard/initializers/load-helpers.js.es6 new file mode 100644 index 00000000000..4b645756a24 --- /dev/null +++ b/app/assets/javascripts/wizard/initializers/load-helpers.js.es6 @@ -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); + } + }); + } +}; diff --git a/app/assets/javascripts/wizard/lib/ajax.js.es6 b/app/assets/javascripts/wizard/lib/ajax.js.es6 new file mode 100644 index 00000000000..b4b78c773c3 --- /dev/null +++ b/app/assets/javascripts/wizard/lib/ajax.js.es6 @@ -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); + }); +} diff --git a/app/assets/javascripts/wizard/mixins/valid-state.js.es6 b/app/assets/javascripts/wizard/mixins/valid-state.js.es6 new file mode 100644 index 00000000000..e26d12f0488 --- /dev/null +++ b/app/assets/javascripts/wizard/mixins/valid-state.js.es6 @@ -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); + } + +}; diff --git a/app/assets/javascripts/wizard/models/step.js.es6 b/app/assets/javascripts/wizard/models/step.js.es6 new file mode 100644 index 00000000000..5fa3b61becb --- /dev/null +++ b/app/assets/javascripts/wizard/models/step.js.es6 @@ -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; + }); + } +}); diff --git a/app/assets/javascripts/wizard/models/wizard-field.js.es6 b/app/assets/javascripts/wizard/models/wizard-field.js.es6 new file mode 100644 index 00000000000..80c5d5e575d --- /dev/null +++ b/app/assets/javascripts/wizard/models/wizard-field.js.es6 @@ -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); + } +}); diff --git a/app/assets/javascripts/wizard/models/wizard.js.es6 b/app/assets/javascripts/wizard/models/wizard.js.es6 new file mode 100644 index 00000000000..b8695616095 --- /dev/null +++ b/app/assets/javascripts/wizard/models/wizard.js.es6 @@ -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); + }); +} + diff --git a/app/assets/javascripts/wizard/resolver.js.es6 b/app/assets/javascripts/wizard/resolver.js.es6 index c89bfa593bf..1feeb307a92 100644 --- a/app/assets/javascripts/wizard/resolver.js.es6 +++ b/app/assets/javascripts/wizard/resolver.js.es6 @@ -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; diff --git a/app/assets/javascripts/wizard/routes/application.js.es6 b/app/assets/javascripts/wizard/routes/application.js.es6 new file mode 100644 index 00000000000..e2aa69ac024 --- /dev/null +++ b/app/assets/javascripts/wizard/routes/application.js.es6 @@ -0,0 +1,7 @@ +import { findWizard } from 'wizard/models/wizard'; + +export default Ember.Route.extend({ + model() { + return findWizard(); + } +}); diff --git a/app/assets/javascripts/wizard/routes/index.js.es6 b/app/assets/javascripts/wizard/routes/index.js.es6 index 2587b35d811..6a497982097 100644 --- a/app/assets/javascripts/wizard/routes/index.js.es6 +++ b/app/assets/javascripts/wizard/routes/index.js.es6 @@ -1,5 +1,6 @@ export default Ember.Route.extend({ beforeModel() { - this.replaceWith('step', 'welcome'); + const appModel = this.modelFor('application'); + this.replaceWith('step', appModel.start); } }); diff --git a/app/assets/javascripts/wizard/routes/step.js.es6 b/app/assets/javascripts/wizard/routes/step.js.es6 index 4de6d180e5d..35dc8df7292 100644 --- a/app/assets/javascripts/wizard/routes/step.js.es6 +++ b/app/assets/javascripts/wizard/routes/step.js.es6 @@ -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') + }); } }); diff --git a/app/assets/javascripts/wizard/templates/application.hbs b/app/assets/javascripts/wizard/templates/application.hbs index 52416fc45ab..1c069d5ba24 100644 --- a/app/assets/javascripts/wizard/templates/application.hbs +++ b/app/assets/javascripts/wizard/templates/application.hbs @@ -1,7 +1,8 @@
- Discourse! - {{outlet}}
+
diff --git a/app/assets/javascripts/wizard/templates/components/wizard-field.hbs b/app/assets/javascripts/wizard/templates/components/wizard-field.hbs new file mode 100644 index 00000000000..b7b1c1a01af --- /dev/null +++ b/app/assets/javascripts/wizard/templates/components/wizard-field.hbs @@ -0,0 +1,7 @@ + diff --git a/app/assets/javascripts/wizard/templates/components/wizard-step.hbs b/app/assets/javascripts/wizard/templates/components/wizard-step.hbs new file mode 100644 index 00000000000..f21c80a9ae6 --- /dev/null +++ b/app/assets/javascripts/wizard/templates/components/wizard-step.hbs @@ -0,0 +1,37 @@ +{{#if step.title}} +

{{step.title}}

+{{/if}} + +{{#if step.description}} +

{{step.description}}

+{{/if}} + +{{#wizard-step-form step=step}} + {{#each step.fields as |field|}} + {{wizard-field field=field}} + {{/each}} +{{/wizard-step-form}} + + diff --git a/app/assets/javascripts/wizard/templates/step.hbs b/app/assets/javascripts/wizard/templates/step.hbs index c8e09a81fb1..2e00198a9ae 100644 --- a/app/assets/javascripts/wizard/templates/step.hbs +++ b/app/assets/javascripts/wizard/templates/step.hbs @@ -1,3 +1 @@ -
- {{step.title}} -
+{{wizard-step step=step wizard=wizard goNext="goNext" goBack="goBack"}} diff --git a/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 b/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 index f166e7b2465..c3a9d16f32d 100644 --- a/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 +++ b/app/assets/javascripts/wizard/test/acceptance/wizard-test.js.es6 @@ -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')); }); }); diff --git a/app/assets/javascripts/wizard/test/models/wizard-field-test.js.es6 b/app/assets/javascripts/wizard/test/models/wizard-field-test.js.es6 new file mode 100644 index 00000000000..45c54989fae --- /dev/null +++ b/app/assets/javascripts/wizard/test/models/wizard-field-test.js.es6 @@ -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')); +}); diff --git a/app/assets/javascripts/wizard/test/test_helper.js b/app/assets/javascripts/wizard/test/test_helper.js index 529c5e9b83b..1b2733092d8 100644 --- a/app/assets/javascripts/wizard/test/test_helper.js +++ b/app/assets/javascripts/wizard/test/test_helper.js @@ -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)) { diff --git a/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 b/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 new file mode 100644 index 00000000000..786c52b7ad7 --- /dev/null +++ b/app/assets/javascripts/wizard/test/wizard-pretender.js.es6 @@ -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; +} diff --git a/app/assets/javascripts/wizard/wizard.js.es6 b/app/assets/javascripts/wizard/wizard.js.es6 index 6318fec2ba0..a806ebc0cbf 100644 --- a/app/assets/javascripts/wizard/wizard.js.es6 +++ b/app/assets/javascripts/wizard/wizard.js.es6 @@ -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); + } + }); + } }); diff --git a/app/assets/stylesheets/wizard.scss b/app/assets/stylesheets/wizard.scss index 228b399f55f..23b30a79b2e 100644 --- a/app/assets/stylesheets/wizard.scss +++ b/app/assets/stylesheets/wizard.scss @@ -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; + } } diff --git a/app/controllers/steps_controller.rb b/app/controllers/steps_controller.rb new file mode 100644 index 00000000000..5dbfa6844e4 --- /dev/null +++ b/app/controllers/steps_controller.rb @@ -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 diff --git a/app/controllers/wizard/wizard_controller.rb b/app/controllers/wizard/wizard_controller.rb deleted file mode 100644 index 09e7ae1f95f..00000000000 --- a/app/controllers/wizard/wizard_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/wizard_controller.rb b/app/controllers/wizard_controller.rb new file mode 100644 index 00000000000..6fed1cf7167 --- /dev/null +++ b/app/controllers/wizard_controller.rb @@ -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 diff --git a/app/serializers/wizard_field_serializer.rb b/app/serializers/wizard_field_serializer.rb new file mode 100644 index 00000000000..838226002bd --- /dev/null +++ b/app/serializers/wizard_field_serializer.rb @@ -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 diff --git a/app/serializers/wizard_serializer.rb b/app/serializers/wizard_serializer.rb new file mode 100644 index 00000000000..ad790a45f88 --- /dev/null +++ b/app/serializers/wizard_serializer.rb @@ -0,0 +1,9 @@ +class WizardSerializer < ApplicationSerializer + attributes :start + + has_many :steps, serializer: WizardStepSerializer, embed: :objects + + def start + object.start.id + end +end diff --git a/app/serializers/wizard_step_serializer.rb b/app/serializers/wizard_step_serializer.rb new file mode 100644 index 00000000000..0adc4486bc3 --- /dev/null +++ b/app/serializers/wizard_step_serializer.rb @@ -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 diff --git a/app/views/wizard/index.html.erb b/app/views/wizard/index.html.erb new file mode 100644 index 00000000000..1021f6aa148 --- /dev/null +++ b/app/views/wizard/index.html.erb @@ -0,0 +1,25 @@ + + + <%= stylesheet_link_tag 'wizard' %> + <%= script 'wizard-vendor' %> + <%= script 'ember_jquery' %> + <%= script 'wizard-application' %> + <%= script "locales/#{I18n.locale}" %> + <%= render partial: "common/special_font_face" %> + + <%= csrf_meta_tags %> + + <%= t 'wizard.title' %> + + + +
+ + + + diff --git a/app/views/wizard/wizard/qunit.html.erb b/app/views/wizard/qunit.html.erb similarity index 88% rename from app/views/wizard/wizard/qunit.html.erb rename to app/views/wizard/qunit.html.erb index 814bfb5c918..93a889e7074 100644 --- a/app/views/wizard/wizard/qunit.html.erb +++ b/app/views/wizard/qunit.html.erb @@ -7,6 +7,7 @@ <%= javascript_include_tag "qunit" %> <%= javascript_include_tag "wizard/test/test_helper" %> <%= csrf_meta_tags %> +
diff --git a/app/views/wizard/wizard/index.html.erb b/app/views/wizard/wizard/index.html.erb deleted file mode 100644 index 249921a4ee6..00000000000 --- a/app/views/wizard/wizard/index.html.erb +++ /dev/null @@ -1,21 +0,0 @@ - - - <%= stylesheet_link_tag 'wizard' %> - <%= javascript_include_tag 'wizard-vendor' %> - <%= javascript_include_tag 'ember_jquery' %> - <%= javascript_include_tag 'wizard-application' %> - - - -
-
- - - - - - diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 22b93e6b826..6f731523b19 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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}" + diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 96c5facc906..f1ed7182b2e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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" + diff --git a/config/routes.rb b/config/routes.rb index 4f516da1397..e86c43efe3a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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" diff --git a/lib/wizard.rb b/lib/wizard.rb new file mode 100644 index 00000000000..612bb123438 --- /dev/null +++ b/lib/wizard.rb @@ -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 diff --git a/lib/wizard/field.rb b/lib/wizard/field.rb new file mode 100644 index 00000000000..3d2de0811c7 --- /dev/null +++ b/lib/wizard/field.rb @@ -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 diff --git a/lib/wizard/step.rb b/lib/wizard/step.rb new file mode 100644 index 00000000000..577d1abb381 --- /dev/null +++ b/lib/wizard/step.rb @@ -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 diff --git a/lib/wizard/step_updater.rb b/lib/wizard/step_updater.rb new file mode 100644 index 00000000000..03cfef47669 --- /dev/null +++ b/lib/wizard/step_updater.rb @@ -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 diff --git a/public/images/wizard/discourse.png b/public/images/wizard/discourse.png new file mode 100644 index 00000000000..c8d7600f7ab Binary files /dev/null and b/public/images/wizard/discourse.png differ diff --git a/spec/components/gaps_spec.rb b/spec/components/gaps_spec.rb index 2e76b16d1ff..3d0943f09ae 100644 --- a/spec/components/gaps_spec.rb +++ b/spec/components/gaps_spec.rb @@ -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 diff --git a/spec/components/step_updater_spec.rb b/spec/components/step_updater_spec.rb new file mode 100644 index 00000000000..40b1244ce26 --- /dev/null +++ b/spec/components/step_updater_spec.rb @@ -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 diff --git a/spec/components/wizard_spec.rb b/spec/components/wizard_spec.rb new file mode 100644 index 00000000000..c206aeb3560 --- /dev/null +++ b/spec/components/wizard_spec.rb @@ -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 diff --git a/spec/controllers/steps_controller_spec.rb b/spec/controllers/steps_controller_spec.rb new file mode 100644 index 00000000000..1d4725ad978 --- /dev/null +++ b/spec/controllers/steps_controller_spec.rb @@ -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 + diff --git a/spec/controllers/wizard/wizard_controller_spec.rb b/spec/controllers/wizard_controller_spec.rb similarity index 63% rename from spec/controllers/wizard/wizard_controller_spec.rb rename to spec/controllers/wizard_controller_spec.rb index d6dcc9a24fd..87fd198b8e0 100644 --- a/spec/controllers/wizard/wizard_controller_spec.rb +++ b/spec/controllers/wizard_controller_spec.rb @@ -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 diff --git a/spec/integrity/i18n_spec.rb b/spec/integrity/i18n_spec.rb index 5a09c0ee2c1..20b69dfa35e 100644 --- a/spec/integrity/i18n_spec.rb +++ b/spec/integrity/i18n_spec.rb @@ -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