Angus McLeod 7 anos atrás
pai
commit
e859e3efa2
39 arquivos alterados com 876 adições e 423 exclusões
  1. 1 14
      app/controllers/admin.rb
  2. 2 3
      app/views/layouts/custom_wizard.html.erb
  3. 9 1
      assets/javascripts/discourse/components/wizard-custom-action.js.es6
  4. 30 6
      assets/javascripts/discourse/components/wizard-custom-field.js.es6
  5. 8 14
      assets/javascripts/discourse/components/wizard-custom-step.js.es6
  6. 15 8
      assets/javascripts/discourse/controllers/admin-wizard.js.es6
  7. 90 75
      assets/javascripts/discourse/models/custom-wizard.js.es6
  8. 18 3
      assets/javascripts/discourse/routes/admin-wizard.js.es6
  9. 5 0
      assets/javascripts/discourse/routes/admin-wizards-custom-index.js.es6
  10. 3 2
      assets/javascripts/discourse/routes/admin-wizards-custom.js.es6
  11. 5 0
      assets/javascripts/discourse/routes/admin-wizards-index.js.es6
  12. 0 1
      assets/javascripts/discourse/routes/admin-wizards.js.es6
  13. 26 4
      assets/javascripts/discourse/templates/admin-wizard.hbs
  14. 1 1
      assets/javascripts/discourse/templates/admin-wizards-submissions.hbs
  15. 3 3
      assets/javascripts/discourse/templates/components/wizard-custom-action.hbs
  16. 62 13
      assets/javascripts/discourse/templates/components/wizard-custom-field.hbs
  17. 13 4
      assets/javascripts/discourse/templates/components/wizard-custom-step.hbs
  18. 0 1
      assets/javascripts/wizard-custom.js
  19. 0 28
      assets/javascripts/wizard/components/wizard-field-composer.js.es6
  20. 5 1
      assets/javascripts/wizard/controllers/custom-step.js.es6
  21. 66 63
      assets/javascripts/wizard/initializers/custom.js.es6
  22. 9 6
      assets/javascripts/wizard/models/custom.js.es6
  23. 12 5
      assets/javascripts/wizard/routes/custom-index.js.es6
  24. 22 2
      assets/javascripts/wizard/routes/custom-step.js.es6
  25. 37 0
      assets/javascripts/wizard/routes/custom.js.es6
  26. 1 16
      assets/javascripts/wizard/templates/application.hbs
  27. 0 1
      assets/javascripts/wizard/templates/components/wizard-field-composer.hbs
  28. 16 0
      assets/javascripts/wizard/templates/custom.hbs
  29. 3 0
      assets/javascripts/wizard/templates/custom.index.hbs
  30. 9 1
      assets/javascripts/wizard/templates/custom.step.hbs
  31. 89 0
      assets/stylesheets/wizard/wizard_custom.scss
  32. 55 7
      assets/stylesheets/custom_wizard.scss
  33. 31 15
      config/locales/client.en.yml
  34. 4 10
      config/locales/server.en.yml
  35. 137 79
      lib/builder.rb
  36. 1 1
      lib/field.rb
  37. 2 1
      lib/wizard.rb
  38. 86 22
      plugin.rb
  39. 0 12
      public/desktop.css

+ 1 - 14
app/controllers/admin.rb

@@ -15,20 +15,7 @@ class CustomWizard::AdminController < ::ApplicationController
 
     wizard = ::JSON.parse(params[:wizard])
 
-    saved = false
-    if wizard["existing_id"] && rows = PluginStoreRow.where(plugin_name: 'custom_wizard').order(:id)
-      rows.each do |r, i|
-        wizard = CustomWizard::Wizard.new(r.value)
-        if wizard.id = wizard["existing_id"]
-          r.update_all(key: wizard['id'], value: wizard)
-          saved = true
-        end
-      end
-    end
-
-    unless saved
-      PluginStore.set('custom_wizard', wizard["id"], wizard)
-    end
+    PluginStore.set('custom_wizard', wizard["id"], wizard)
 
     render json: success_json
   end

+ 2 - 3
app/views/layouts/custom_wizard.html.erb

@@ -1,8 +1,7 @@
 <html>
   <head>
-    <link href="<%= Discourse.base_uri %>/plugins/discourse-custom-wizard/desktop.css" media="all" rel="stylesheet" data-target="desktop" type="text/css" />
     <%= discourse_stylesheet_link_tag :wizard, theme_key: nil %>
-    <%= discourse_stylesheet_link_tag(mobile_view? ? :mobile : :desktop) %>
+    <%= stylesheet_link_tag "wizard_custom", media: "all", "data-turbolinks-track" => "reload" %>
     <%= preload_script "ember_jquery" %>
     <%= preload_script "wizard-vendor" %>
     <%= preload_script "wizard-application" %>
@@ -18,7 +17,7 @@
     <meta name="discourse-base-uri" content="<%= Discourse.base_uri %>">
 
     <%= render partial: "layouts/head" %>
-    <title><%= t 'custom_wizard.title' %></title>
+    <title><%= t 'wizard.custom_title' %></title>
   </head>
 
   <body class='custom-wizard'>

+ 9 - 1
assets/javascripts/discourse/components/wizard-custom-action.js.es6

@@ -1,8 +1,16 @@
+import { on, observes } from 'ember-addons/ember-computed-decorators';
+
 export default Ember.Component.extend({
   classNames: 'wizard-custom-action',
   types: ['create_topic', 'update_profile', 'send_message'],
   profileFields: ['name', 'username', 'email'],
   createTopic: Ember.computed.equal('action.type', 'create_topic'),
   updateProfile: Ember.computed.equal('action.type', 'update_profile'),
-  sendMessage: Ember.computed.equal('action.type', 'send_message')
+  sendMessage: Ember.computed.equal('action.type', 'send_message'),
+
+  @on('init')
+  @observes('action')
+  setup() {
+    this.set('existingId', this.get('action.id'));
+  }
 });

+ 30 - 6
assets/javascripts/discourse/components/wizard-custom-field.js.es6

@@ -5,19 +5,43 @@ export default Ember.Component.extend({
   isDropdown: Ember.computed.equal('field.type', 'dropdown'),
 
   @on('init')
-  @observes('field.id')
-  init() {
-    this._super(...arguments);
-    if (!this.get('field.choices')) {
-      this.set('field.choices', Ember.A());
-    }
+  @observes('field')
+  setup() {
+    this.set('existingId', this.get('field.id'));
   },
 
+  @computed('field.type')
+  isInput: (type) => type === 'text' || type === 'textarea',
+
   @computed('field.choices.[]')
   dropdownChoices: choices => choices,
 
+  @computed('field.choices_filters.[]')
+  presetFilters: filters => filters,
+
+  @computed()
+  presetChoices() {
+    return [
+      { id: 'categories', name: I18n.t('admin.wizard.field.choices_preset.categories') }
+    ];
+  },
+
   actions: {
+    addFilter() {
+      if (!this.get('field.choices_filters')) {
+        this.set('field.choices_filters', Ember.A());
+      }
+      this.get('field.choices_filters').pushObject(Ember.Object.create());
+    },
+
+    removeFilter(f) {
+      this.get('field.choices_filters').removeObject(f);
+    },
+
     addChoice() {
+      if (!this.get('field.choices')) {
+        this.set('field.choices', Ember.A());
+      }
       this.get('field.choices').pushObject(Ember.Object.create());
     },
 

+ 8 - 14
assets/javascripts/discourse/components/wizard-custom-step.js.es6

@@ -7,15 +7,15 @@ export default Ember.Component.extend({
 
   @on('init')
   @observes('step')
-  setup() {
-    this._super(...arguments);
+  setCurrent() {
+    this.set('existingId', this.get('step.id'));
     const fields = this.get('step.fields') || [];
     const actions = this.get('step.actions') || [];
     this.set('currentField', fields[0]);
     this.set('currentAction', actions[0]);
   },
 
-  @computed('step.fields.[]', 'currentField')
+  @computed('step.fields.@each.id', 'currentField')
   fieldLinks(fields, current) {
     if (!fields) return;
 
@@ -24,7 +24,7 @@ export default Ember.Component.extend({
         const id = f.get('id');
         const label = f.get('label');
 
-        let link = { id, label: label || id };
+        let link = { id, label: label || id || 'new' };
 
         let classes = 'btn';
         if (current && f.get('id') === current.get('id')) {
@@ -38,7 +38,7 @@ export default Ember.Component.extend({
     });
   },
 
-  @computed('step.actions.[]', 'currentAction')
+  @computed('step.actions.@each.id', 'currentAction')
   actionLinks(actions, current) {
     if (!actions) return;
 
@@ -47,7 +47,7 @@ export default Ember.Component.extend({
         const id = a.get('id');
         const label = a.get('label');
 
-        let link = { id, label: label || id };
+        let link = { id, label: label || id || 'new' };
 
         let classes = 'btn';
         if (current && a.get('id') === current.get('id')) {
@@ -64,20 +64,14 @@ export default Ember.Component.extend({
   actions: {
     addField() {
       const fields = this.get('step.fields');
-      const newNum = fields.length + 1;
-      const field = Ember.Object.create({
-        id: `field-${newNum}`
-      });
+      const field = Ember.Object.create();
       fields.pushObject(field);
       this.set('currentField', field);
     },
 
     addAction() {
       const actions = this.get('step.actions');
-      const newNum = actions.length + 1;
-      const action = Ember.Object.create({
-        id: `action-${newNum}`
-      });
+      const action = Ember.Object.create();
       actions.pushObject(action);
       this.set('currentAction', action);
     },

+ 15 - 8
assets/javascripts/discourse/controllers/admin-wizard.js.es6

@@ -1,15 +1,14 @@
 import { default as computed } from 'ember-addons/ember-computed-decorators';
 
 export default Ember.Controller.extend({
-
-  @computed('model.steps.[]', 'currentStep')
+  @computed('model.steps.@each.id', 'currentStep')
   stepLinks(steps, currentStep) {
     return steps.map((s) => {
       if (s) {
         const id = s.get('id');
         const title = s.get('title');
 
-        let link = { id, title: title || id };
+        let link = { id, title: title || id || 'new' };
 
         let classes = 'btn';
         if (currentStep && id === currentStep.get('id')) {
@@ -25,17 +24,27 @@ export default Ember.Controller.extend({
 
   @computed('model.id', 'model.name')
   wizardUrl(wizardId) {
-    return window.location.origin + '/wizard/custom/' + Ember.String.dasherize(wizardId);
+    return window.location.origin + '/w/' + Ember.String.dasherize(wizardId);
   },
 
   actions: {
     save() {
-      this.get('model').save().then(() => {
+      this.setProperties({
+        saving: true,
+        error: null
+      });
+      const wizard = this.get('model');
+      wizard.save().then(() => {
+        this.set('saving', false);
         if (this.get('newWizard')) {
           this.send("refreshAllWizards");
         } else {
           this.send("refreshWizard");
         }
+      }).catch((error) => {
+        this.set('saving', false);
+        this.set('error', I18n.t(`admin.wizard.error.${error}`));
+        Ember.run.later(() => this.set('error', null), 10000);
       });
     },
 
@@ -47,11 +56,9 @@ export default Ember.Controller.extend({
 
     addStep() {
       const steps = this.get('model.steps');
-      const newNum = steps.length + 1;
       const step = Ember.Object.create({
         fields: Ember.A(),
-        actions: Ember.A(),
-        id: `step-${newNum}`
+        actions: Ember.A()
       });
       steps.pushObject(step);
       this.set('currentStep', step);

+ 90 - 75
assets/javascripts/discourse/models/custom-wizard.js.es6

@@ -1,90 +1,90 @@
-import { observes, on } from 'ember-addons/ember-computed-decorators';
 import { ajax } from 'discourse/lib/ajax';
 
 const CustomWizard = Discourse.Model.extend({
-  @on('init')
-  setup() {
-    const id = this.get('id');
-    if (id) this.set('existingId', id);
-  },
+  save() {
+    return new Ember.RSVP.Promise((resolve, reject) => {
+      const id = this.get('id');
+      if (!id || !id.underscore()) reject('id_required');
+
+      let wizard = { id: id.underscore() };
+
+      const steps = this.get('steps');
+      if (steps.length) wizard['steps'] = this.buildSteps(steps, reject);
+
+      const name = this.get('name');
+      if (name) wizard['name'] = name;
+
+      const background = this.get('background');
+      if (background) wizard['background'] = background;
+
+      const save_submissions = this.get('save_submissions');
+      if (save_submissions) wizard['save_submissions'] = save_submissions;
+
+      const multiple_submissions = this.get('multiple_submissions');
+      if (multiple_submissions) wizard['multiple_submissions'] = multiple_submissions;
 
-  @observes('name')
-  updateId() {
-    const name = this.get('name');
-    this.set('id', name.underscore());
+      ajax("/admin/wizards/custom/save", {
+        type: 'PUT',
+        data: {
+          wizard: JSON.stringify(wizard)
+        }
+      }).then((result) => resolve(result));
+    });
   },
 
-  save() {
-    const stepsObj = this.get('steps');
+  buildSteps(stepsObj, reject) {
     let steps = [];
 
-    stepsObj.forEach((s) => {
-
-      if (!s.title && !s.translation_key) return;
+    stepsObj.some((s) => {
+      if (!s.id || !s.id.underscore()) reject('id_required');
 
-      let step = {
-        id: (s.title || s.translation_key.split('.').pop()).underscore(),
-        fields: [],
-        actions: []
-      };
+      let step = { id: s.id.underscore() };
 
       if (s.title) step['title'] = s.title;
-      if (s.translation_key) step['translation_key'] = s.translation_key;
+      if (s.key) step['key'] = s.key;
       if (s.banner) step['banner'] = s.banner;
       if (s.description) step['description'] = s.description;
 
       const fields = s.get('fields');
-      fields.forEach((f) => {
-        const fl = f.get('label');
-        const fkey = f.get('translation_key');
+      if (fields.length) {
+        step['fields'] = [];
 
-        if (!fl && !fkey) return;
+        fields.some((f) => {
+          let id = f.get('id');
 
-        f.set('id', (fl || fkey.split('.').pop()).underscore());
+          if (!id || !id.underscore()) reject('id_required');
+          f.set('id', id.underscore());
 
-        if (f.get('type') === 'dropdown') {
-          const choices = f.get('choices');
+          if (f.get('type') === 'dropdown') {
+            const choices = f.get('choices');
+            if (choices && choices.length < 1 && !f.get('choices_key') && !f.get('choices_categories')) {
+              reject('field.need_choices');
+            }
+          }
 
-          choices.forEach((c) => {
-            const cl = c.get('label');
-            const ckey = c.get('translation_key');
+          step['fields'].push(f);
+        });
+      }
 
-            if (!cl && !ckey) return;
+      const actions = s.actions;
+      if (actions.length) {
+        step['actions'] = [];
 
-            c.set('id', (cl || ckey.split('.').pop()).underscore());
-          });
-        }
+        actions.some((a) => {
+          let id = a.get('id');
+          if (!id || !id.underscore()) reject('id_required');
 
-        step['fields'].push(f);
-      });
+          a.set('id', id.underscore());
 
-      s.actions.forEach((a) => {
-        const al = a.get('label');
-        if (!al) return;
-        a.set('id', al.underscore());
-        step['actions'].push(a);
-      });
+          step['actions'].push(a);
+        });
+
+      }
 
       steps.push(step);
     });
 
-    const id = this.get('id');
-    const name = this.get('name');
-    const background = this.get('background');
-    const save_submissions = this.get('save_submissions');
-    let wizard = { id, name, background, save_submissions, steps };
-
-    const existingId = this.get('existingId');
-    if (existingId && existingId !== id) {
-      wizard['existing_id'] = existingId;
-    };
-
-    return ajax("/admin/wizards/custom/save", {
-      type: 'PUT',
-      data: {
-        wizard: JSON.stringify(wizard)
-      }
-    });
+    return steps;
   },
 
   remove() {
@@ -121,35 +121,49 @@ CustomWizard.reopenClass({
 
     if (w) {
       props['id'] = w.id;
+      props['existingId'] = true;
       props['name'] = w.name;
       props['background'] = w.background;
       props['save_submissions'] = w.save_submissions;
+      props['multiple_submissions'] = w.multiple_submissions;
 
-      if (w.steps) {
+      if (w.steps && w.steps.length) {
         w.steps.forEach((s) => {
-          let fields = Ember.A();
+          // clean empty strings
+          Object.keys(s).forEach((key) => (s[key] === '') && delete s[key]);
 
-          s.fields.forEach((f) => {
-            let field = Ember.Object.create(f);
-            let choices = Ember.A();
+          let fields =  Ember.A();
 
-            f.choices.forEach((c) => {
-              choices.pushObject(Ember.Object.create(c));
-            });
+          if (s.fields && s.fields.length) {
+            s.fields.forEach((f) => {
+              Object.keys(f).forEach((key) => (f[key] === '') && delete f[key]);
+
+              let field = Ember.Object.create(f);
+
+              if (f.choices) {
+                let choices = Ember.A();
 
-            field.set('choices', choices);
+                f.choices.forEach((c) => {
+                  choices.pushObject(Ember.Object.create(c));
+                });
 
-            fields.pushObject(field);
-          });
+                field.set('choices', choices);
+              }
+
+              fields.pushObject(field);
+            });
+          }
 
           let actions = Ember.A();
-          s.actions.forEach((a) => {
-            actions.pushObject(Ember.Object.create(a));
-          });
+          if (s.actions && s.actions.length) {
+            s.actions.forEach((a) => {
+              actions.pushObject(Ember.Object.create(a));
+            });
+          }
 
           steps.pushObject(Ember.Object.create({
             id: s.id,
-            translation_key: s.translation_key,
+            key: s.key,
             title: s.title,
             description: s.description,
             banner: s.banner,
@@ -163,6 +177,7 @@ CustomWizard.reopenClass({
       props['name'] = '';
       props['background'] = '';
       props['save_submissions'] = true;
+      props['multiple_submissions'] = false;
       props['steps'] = Ember.A();
     };
 

+ 18 - 3
assets/javascripts/discourse/routes/admin-wizard.js.es6

@@ -2,15 +2,30 @@ import CustomWizard from '../models/custom-wizard';
 import { ajax } from 'discourse/lib/ajax';
 
 export default Discourse.Route.extend({
+  beforeModel() {
+    const param = this.paramsFor('adminWizard').wizard_id;
+    const wizards = this.modelFor('admin-wizards-custom');
+
+    if (wizards.length && (param === 'first' || param === 'last')) {
+      const wizard = wizards.get(`${param}Object`);
+      if (wizard) {
+        this.transitionTo('adminWizard', wizard.id.dasherize());
+      }
+    }
+  },
+
   model(params) {
-    if (params.wizard_id === 'new') {
+    const wizardId = params.wizard_id;
+
+    if (wizardId === 'new') {
       this.set('newWizard', true);
       return CustomWizard.create();
     };
     this.set('newWizard', false);
 
-    const wizard = this.modelFor('admin-wizards-custom').findBy('id', params.wizard_id.underscore());
-    if (!wizard) return this.transitionTo('adminWizardsCustom.index');
+    const wizard = this.modelFor('admin-wizards-custom').findBy('id', wizardId.underscore());
+
+    if (!wizard) return this.transitionTo('adminWizard', 'new');
 
     return wizard;
   },

+ 5 - 0
assets/javascripts/discourse/routes/admin-wizards-custom-index.js.es6

@@ -0,0 +1,5 @@
+export default Discourse.Route.extend({
+  redirect() {
+    this.transitionTo('adminWizard', 'first');
+  }
+});

+ 3 - 2
assets/javascripts/discourse/routes/admin-wizards-custom.js.es6

@@ -7,8 +7,9 @@ export default Discourse.Route.extend({
 
   afterModel(model) {
     const transitionToWizard = this.get('transitionToWizard');
-    if (transitionToWizard === 'last' && model.length) {
-      this.transitionTo('adminWizard', model[model.length - 1].id);
+    if (transitionToWizard && model.length) {
+      this.set('transitionToWizard', null);
+      this.transitionTo('adminWizard', transitionToWizard);
     };
   },
 

+ 5 - 0
assets/javascripts/discourse/routes/admin-wizards-index.js.es6

@@ -0,0 +1,5 @@
+export default Discourse.Route.extend({
+  redirect() {
+    this.transitionTo('adminWizardsCustom');
+  }
+});

+ 0 - 1
assets/javascripts/discourse/routes/admin-wizards.js.es6

@@ -1 +0,0 @@
-export default Discourse.Route.extend();

+ 26 - 4
assets/javascripts/discourse/templates/admin-wizard.hbs

@@ -6,10 +6,10 @@
 
   <div class="setting">
     <div class="setting-label">
-      <h3>{{i18n 'admin.wizard.background'}}</h3>
+      <h3>{{i18n 'admin.wizard.id'}}</h3>
     </div>
     <div class="setting-value">
-      {{input name="background" value=model.background placeholderKey="admin.wizard.background_placeholder"}}
+      {{input name="name" value=model.id placeholderKey="admin.wizard.id_placeholder" disabled=model.existingId}}
     </div>
   </div>
 
@@ -22,6 +22,15 @@
     </div>
   </div>
 
+  <div class="setting">
+    <div class="setting-label">
+      <h3>{{i18n 'admin.wizard.background'}}</h3>
+    </div>
+    <div class="setting-value">
+      {{input name="background" value=model.background placeholderKey="admin.wizard.background_placeholder"}}
+    </div>
+  </div>
+
   <div class="setting">
     <div class="setting-label">
       <h3>{{i18n 'admin.wizard.save_submissions'}}</h3>
@@ -32,6 +41,16 @@
     </div>
   </div>
 
+  <div class="setting full">
+    <div class="setting-label">
+      <h3>{{i18n 'admin.wizard.multiple_submissions'}}</h3>
+    </div>
+    <div class="setting-value">
+      {{input type='checkbox' checked=model.multiple_submissions}}
+      <span for="save">{{i18n 'admin.wizard.multiple_submissions_label'}}</span>
+    </div>
+  </div>
+
   <div class="setting full">
     <div class="setting-label">
       <h3>{{i18n 'admin.wizard.url'}}</h3>
@@ -55,8 +74,11 @@
   <div class='buttons'>
     <button {{action "save"}} disabled={{disableSave}} class='btn btn-primary'>{{i18n 'admin.wizard.save'}}</button>
     {{#unless newWizard}}
-      <button {{action "remove"}} class='btn btn-danger'>{{d-icon "trash-o"}}{{i18n 'admin.wizard.remove'}}</button>
+      <button {{action "remove"}} class='btn btn-danger remove'>{{d-icon "trash-o"}}{{i18n 'admin.wizard.remove'}}</button>
     {{/unless}}
-    <span class="saving {{unless savingStatus 'hidden'}}">{{savingStatus}}</span>
+    {{conditional-loading-spinner condition=saving size='small'}}
+    {{#if error}}
+      <span class="error">{{d-icon "times"}}{{error}}</span>
+    {{/if}}
   </div>
 </div>

+ 1 - 1
assets/javascripts/discourse/templates/admin-wizards-submissions.hbs

@@ -1,5 +1,5 @@
 <div class='row'>
-  <div class='wizard-list'>
+  <div class='content-list wizard-list'>
     <ul>
       {{#each model as |s|}}
         <li>

+ 3 - 3
assets/javascripts/discourse/templates/components/wizard-custom-action.hbs

@@ -1,15 +1,15 @@
 <div class="setting">
   <div class="setting-label">
-    <h3>{{i18n "admin.wizard.action.label"}}</h3>
+    <h3>{{i18n "admin.wizard.action.id"}}</h3>
   </div>
   <div class="setting-value">
-    {{input value=action.label}}
+    {{input value=action.id placeholderKey='admin.wizard.id_placeholder' disabled=existingId}}
   </div>
 </div>
 
 <div class="setting">
   <div class="setting-label">
-    <h3>{{i18n "admin.wizard.action.type"}}</h3>
+    <h3>{{i18n "admin.wizard.type"}}</h3>
   </div>
   <div class="setting-value">
     {{combo-box value=action.type content=types}}

+ 62 - 13
assets/javascripts/discourse/templates/components/wizard-custom-field.hbs

@@ -1,9 +1,18 @@
 <div class="setting">
   <div class="setting-label">
-    <h3>{{i18n 'admin.wizard.translation'}}</h3>
+    <h3>{{i18n 'admin.wizard.id'}}</h3>
   </div>
   <div class="setting-value">
-    {{input name="translation_key" value=field.translation_key placeholderKey="admin.wizard.field.translation_placeholder"}}
+    {{input name="id" value=field.id placeholderKey="admin.wizard.id_placeholder" disabled=existingId}}
+  </div>
+</div>
+
+<div class="setting">
+  <div class="setting-label">
+    <h3>{{i18n 'admin.wizard.key'}}</h3>
+  </div>
+  <div class="setting-value">
+    {{input name="key" value=field.key placeholderKey="admin.wizard.key_placeholder"}}
   </div>
 </div>
 
@@ -12,7 +21,7 @@
     <h3>{{i18n 'admin.wizard.field.label'}}</h3>
   </div>
   <div class="setting-value">
-    {{input name="label" value=field.label}}
+    {{input name="label" value=field.label placeholder=(i18n "admin.wizard.custom_text_placeholder")}}
   </div>
 </div>
 
@@ -21,13 +30,13 @@
     <h3>{{i18n 'admin.wizard.field.description'}}</h3>
   </div>
   <div class="setting-value">
-    {{textarea name="description" value=field.description}}
+    {{textarea name="description" value=field.description placeholder=(i18n "admin.wizard.custom_text_placeholder")}}
   </div>
 </div>
 
 <div class="setting">
   <div class="setting-label">
-    <h3>{{i18n 'admin.wizard.field.type'}}</h3>
+    <h3>{{i18n 'admin.wizard.type'}}</h3>
   </div>
   <div class="setting-value">
     {{combo-box value=field.type content=types}}
@@ -44,17 +53,57 @@
   </div>
 </div>
 
+{{#if isInput}}
+  <div class="setting">
+    <div class="setting-label">
+      <h3>{{i18n 'admin.wizard.field.min_length'}}</h3>
+    </div>
+    <div class="setting-value">
+      {{input type="number" name="min_length" value=field.min_length placeholder=(i18n 'admin.wizard.field.min_length_placeholder')}}
+    </div>
+  </div>
+{{/if}}
+
 {{#if isDropdown}}
   <div class="wizard-dropdown-choices">
-    <div class="wizard-header small">
+    <div class="wizard-header medium">
       {{i18n 'admin.wizard.field.choices_label'}}
     </div>
-    {{#each dropdownChoices as |c|}}
-      <span class='wizard-dropdown-choice'>
-        {{input type='text' value=c.label}}
-      </span>
-      {{d-button action='removeChoice' actionParam=c icon='times'}}
-    {{/each}}
-    {{d-button action='addChoice' label='admin.wizard.add' icon='plus'}}
+    <div class="setting">
+      <div class="wizard-header small">
+        {{i18n 'admin.wizard.field.choices_translation'}}
+      </div>
+      <div class="setting-value">
+        {{input name="key" value=field.choices_key placeholderKey="admin.wizard.key_placeholder"}}
+      </div>
+    </div>
+    <div class="setting full">
+      <div class="wizard-header small">
+        {{i18n 'admin.wizard.field.choices_preset.label'}}
+      </div>
+      {{combo-box value=field.choices_preset content=presetChoices none='admin.wizard.field.choices_preset.none'}}
+      <label>{{i18n 'admin.wizard.field.choices_preset.filter'}}</label>
+      {{#each presetFilters as |f|}}
+        <span class='custom-input'>
+          {{input type="text" value=f.key placeholder=(i18n 'admin.wizard.field.choices_preset.key')}}
+          {{input type="text" value=f.value placeholder=(i18n 'admin.wizard.field.choices_preset.value')}}
+        </span>
+        {{d-button action='removeFilter' actionParam=f icon='times'}}
+      {{/each}}
+      <div>{{d-button action='addFilter' label='admin.wizard.add' icon='plus'}}</div>
+    </div>
+    <div class="setting full">
+      <div class="wizard-header small">
+        {{i18n 'admin.wizard.field.choices_custom'}}
+      </div>
+      {{#each dropdownChoices as |c|}}
+        <span class='custom-input'>
+          {{input type='text' value=c.value placeholder=(i18n 'admin.wizard.field.choice.value')}}
+          {{input type='text' value=c.label placeholder=(i18n 'admin.wizard.field.choice.label')}}
+        </span>
+        {{d-button action='removeChoice' actionParam=c icon='times'}}
+      {{/each}}
+      <div>{{d-button action='addChoice' label='admin.wizard.add' icon='plus'}}</div>
+    </div>
   </div>
 {{/if}}

+ 13 - 4
assets/javascripts/discourse/templates/components/wizard-custom-step.hbs

@@ -1,9 +1,18 @@
 <div class="setting">
   <div class="setting-label">
-    <h3>{{i18n 'admin.wizard.translation'}}</h3>
+    <h3>{{i18n 'admin.wizard.id'}}</h3>
   </div>
   <div class="setting-value">
-    {{input name="translation_key" value=step.translation_key placeholderKey="admin.wizard.step.translation_placeholder"}}
+    {{input name="id" value=step.id placeholderKey="admin.wizard.id_placeholder" disabled=existingId}}
+  </div>
+</div>
+
+<div class="setting">
+  <div class="setting-label">
+    <h3>{{i18n 'admin.wizard.key'}}</h3>
+  </div>
+  <div class="setting-value">
+    {{input name="key" value=step.key placeholderKey="admin.wizard.key_placeholder"}}
   </div>
 </div>
 
@@ -12,7 +21,7 @@
     <h3>{{i18n 'admin.wizard.step.title'}}</h3>
   </div>
   <div class="setting-value">
-    {{input name="title" value=step.title placeholderKey="admin.wizard.step.title_placeholder"}}
+    {{input name="title" value=step.title placeholderKey="admin.wizard.custom_text_placeholder"}}
   </div>
 </div>
 
@@ -30,7 +39,7 @@
     <h3>{{i18n 'admin.wizard.step.description'}}</h3>
   </div>
   <div class="setting-value">
-    {{textarea name="description" value=step.description placeholder=(i18n "admin.wizard.step.description_placeholder")}}
+    {{textarea name="description" value=step.description placeholder=(i18n "admin.wizard.custom_text_placeholder")}}
   </div>
 </div>
 

+ 0 - 1
assets/javascripts/wizard-custom.js

@@ -1,5 +1,4 @@
 //= require ./wizard/custom-wizard
-//= require_tree ./wizard/components
 //= require_tree ./wizard/controllers
 //= require_tree ./wizard/helpers
 //= require_tree ./wizard/initializers

+ 0 - 28
assets/javascripts/wizard/components/wizard-field-composer.js.es6

@@ -1,28 +0,0 @@
-import { observes } from 'ember-addons/ember-computed-decorators';
-
-export default Ember.Component.extend({
-  classNames: 'wizard-field-composer',
-
-  keyPress(e) {
-    e.stopPropagation();
-  },
-
-  @observes('field.value')
-  validate() {
-    const minLength = Wizard.SiteSettings.min_post_length;
-    const post = this.get('field.value');
-    const field = this.get('field');
-
-    field.set('customValidation', true);
-
-    if (!post) {
-      return field.setValid(false);
-    }
-
-    if (minLength && post.length < minLength) {
-      return field.setValid(false, I18n.t('wizard.validation.too_short', { min: minLength }));
-    }
-
-    field.setValid(true);
-  }
-});

+ 5 - 1
assets/javascripts/wizard/controllers/custom-step.js.es6

@@ -7,7 +7,7 @@ export default StepController.extend({
       const next = this.get('step.next');
       if (response.refresh_required) {
         const id = this.get('wizard.id');
-        document.location = getUrl(`/wizard/custom/${id}/steps/${next}`);
+        document.location = getUrl(`/w/${id}/steps/${next}`);
       } else {
         this.transitionToRoute('custom.step', next);
       }
@@ -15,6 +15,10 @@ export default StepController.extend({
 
     goBack() {
       this.transitionToRoute('custom.step', this.get('step.previous'));
+    },
+
+    showMessage(message) {
+      this.set('stepMessage', message);
     }
   }
 });

+ 66 - 63
assets/javascripts/wizard/initializers/custom.js.es6

@@ -4,80 +4,27 @@ export default {
   initialize(app) {
     if (app.constructor.name !== 'Class' || app.get('rootElement') !== '#custom-wizard-main') return;
 
-    const WizardApplicationRoute = requirejs('wizard/routes/application').default;
-    const findCustomWizard = requirejs('discourse/plugins/discourse-custom-wizard/wizard/models/custom').findCustomWizard;
     const Router = requirejs('wizard/router').default;
+    const ApplicationRoute = requirejs('wizard/routes/application').default;
     const ajax = requirejs('wizard/lib/ajax').ajax;
-    const StepRoute = requirejs('wizard/routes/step').default;
     const StepModel = requirejs('wizard/models/step').default;
     const WizardStep = requirejs('wizard/components/wizard-step').default;
     const getUrl = requirejs('discourse-common/lib/get-url').default;
     const FieldModel = requirejs('wizard/models/wizard-field').default;
 
+    Router.reopen({
+      rootURL: getUrl('/w/')
+    });
+
     Router.map(function() {
-      this.route('custom', { path: '/custom/:id' }, function() {
+      this.route('custom', { path: '/:wizard_id' }, function() {
         this.route('step', { path: '/steps/:step_id' });
       });
     });
 
-    WizardApplicationRoute.reopen({
-      model() {
-        const customParams = this.paramsFor('custom');
-        return findCustomWizard(customParams.id);
-      },
-
-      afterModel(model) {
-        return Ember.RSVP.hash({
-          info: ajax({
-            url: `/site/basic-info`,
-            type: 'GET',
-          }).then((result) => {
-            return model.set('siteInfo', result);
-          }),
-          settings: ajax({
-            url: `/site/settings`,
-            type: 'GET',
-          }).then((result) => {
-            Object.assign(Wizard.SiteSettings, result);
-          })
-        });
-      },
-
-      setupController(controller, model) {
-        Ember.run.scheduleOnce('afterRender', this, function(){
-          $('body.custom-wizard').css('background', model.get('background'));
-        });
-
-        controller.setProperties({
-          customWizard: true,
-          siteInfo: model.get('siteInfo')
-        });
-      }
-    });
-
-    StepModel.reopen({
-      save() {
-        const fields = {};
-        this.get('fields').forEach(f => fields[f.id] = f.value);
-        return ajax({
-          url: `/wizard/custom/${this.get('wizardId')}/steps/${this.get('id')}`,
-          type: 'PUT',
-          data: { fields }
-        }).catch(response => {
-          response.responseJSON.errors.forEach(err => this.fieldError(err.field, err.description));
-          throw response;
-        });
-      }
-    });
-
-    StepRoute.reopen({
-      afterModel(model) {
-        if (!model) {
-          return document.location = getUrl("/");
-        }
-
-        const wizard = this.modelFor('application');
-        return model.set("wizardId", wizard.id);
+    ApplicationRoute.reopen({
+      redirect() {
+        this.transitionTo('custom');
       }
     });
 
@@ -93,12 +40,17 @@ export default {
         };
       }.property('step.banner'),
 
+      handleMessage: function() {
+        const message = this.get('step.message');
+        this.sendAction('showMessage', message);
+      }.observes('step.message'),
+
       advance() {
         this.set('saving', true);
         this.get('step').save()
           .then(response => {
             if (this.get('finalStep')) {
-              document.location = getUrl("/");
+              this.sendAction('finished', response);
             } else {
               this.sendAction('goNext', response);
             }
@@ -111,10 +63,61 @@ export default {
         quit() {
           this.set('finalStep', true);
           this.send('nextStep');
+        },
+
+        showMessage(message) {
+          this.sendAction('showMessage', message);
         }
       }
     });
 
+    StepModel.reopen({
+      save() {
+        const wizardId = this.get('wizardId');
+        const fields = {};
+        this.get('fields').forEach(f => fields[f.id] = f.value);
+        return ajax({
+          url: `/w/${wizardId}/steps/${this.get('id')}`,
+          type: 'PUT',
+          data: { fields }
+        }).catch(response => {
+          if (response && response.responseJSON && response.responseJSON.errors) {
+            let wizardErrors = [];
+            response.responseJSON.errors.forEach(err => {
+              if (err.field === wizardId) {
+                wizardErrors.push(err.description);
+              } else if (err.field) {
+                this.fieldError(err.field, err.description);
+              } else if (err) {
+                wizardErrors.push(err);
+              }
+            });
+            if (wizardErrors.length) {
+              this.handleWizardError(wizardErrors.join('\n'));
+            }
+            throw response;
+          }
+
+          if (response && response.responseText) {
+            const responseText = response.responseText;
+            const start = responseText.indexOf('>') + 1;
+            const end = responseText.indexOf('plugins');
+            const message = responseText.substring(start, end);
+            this.handleWizardError(message);
+            throw message;
+          }
+        });
+      },
+
+      handleWizardError(message) {
+        this.set('message', {
+          state: 'error',
+          text: message
+        });
+        Ember.run.later(() => this.set('message', null), 6000);
+      }
+    });
+
     FieldModel.reopen({
       check() {
         let valid = this.get('valid');

+ 9 - 6
assets/javascripts/wizard/models/custom.js.es6

@@ -9,13 +9,16 @@ const CustomWizard = Ember.Object.extend({
 });
 
 export function findCustomWizard(wizardId) {
-  return ajax({ url: `/wizard/custom/${wizardId}` }).then(result => {
+  return ajax({ url: `/w/${wizardId}` }).then(result => {
     const wizard = result.wizard;
-    wizard.steps = wizard.steps.map(step => {
-      const stepObj = Step.create(step);
-      stepObj.fields = stepObj.fields.map(f => WizardField.create(f));
-      return stepObj;
-    });
+
+    if (!wizard.completed) {
+      wizard.steps = wizard.steps.map(step => {
+        const stepObj = Step.create(step);
+        stepObj.fields = stepObj.fields.map(f => WizardField.create(f));
+        return stepObj;
+      });
+    }
 
     return CustomWizard.create(wizard);
   });

+ 12 - 5
assets/javascripts/wizard/routes/custom-index.js.es6

@@ -1,8 +1,15 @@
-import IndexRoute from 'wizard/routes/index';
-
-export default IndexRoute.extend({
+export default Ember.Route.extend({
   beforeModel() {
-    const appModel = this.modelFor('application');
-    this.replaceWith('custom.step', appModel.start);
+    const appModel = this.modelFor('custom');
+    if (appModel.completed) {
+      this.set('completed', true);
+    } else if (appModel.start) {
+      this.replaceWith('custom.step', appModel.start);
+    }
+  },
+
+  setupController(controller) {
+    const completed = this.get('completed');
+    controller.set('completed', completed);
   }
 });

+ 22 - 2
assets/javascripts/wizard/routes/custom-step.js.es6

@@ -1,3 +1,23 @@
-import StepRoute from 'wizard/routes/step';
+export default Ember.Route.extend({
+  model(params) {
+    const appModel = this.modelFor('custom');
+    const allSteps = appModel.steps;
+    if (allSteps) {
+      const step = allSteps.findBy('id', params.step_id);
+      return step ? step : allSteps[0];
+    };
 
-export default StepRoute.extend();
+    return appModel;
+  },
+
+  afterModel(model) {
+    if (model.completed) return this.transitionTo('index');
+    return model.set("wizardId", this.modelFor('custom').id);
+  },
+
+  setupController(controller, step) {
+    controller.setProperties({
+      step, wizard: this.modelFor('custom')
+    });
+  }
+});

+ 37 - 0
assets/javascripts/wizard/routes/custom.js.es6

@@ -0,0 +1,37 @@
+import { findCustomWizard } from '../models/custom';
+import { ajax } from 'wizard/lib/ajax';
+import { getUrl } from 'discourse-common/lib/get-url';
+
+export default Ember.Route.extend({
+  model(params) {
+    return findCustomWizard(params.wizard_id);
+  },
+
+  afterModel() {
+    return ajax({
+      url: `/site/settings`,
+      type: 'GET',
+    }).then((result) => {
+      Object.assign(Wizard.SiteSettings, result);
+    });
+  },
+
+  setupController(controller, model) {
+    Ember.run.scheduleOnce('afterRender', this, function(){
+      $('body.custom-wizard').css('background', model.get('background'));
+    });
+
+    controller.setProperties({
+      customWizard: true,
+      logoUrl: Wizard.SiteSettings.logo_small_url
+    });
+  },
+
+  actions: {
+    finished(result) {
+      let url = "/";
+      if (result.topic_id) url += `t/${result.topic_id}`;
+      document.location.replace(getUrl(url));
+    }
+  }
+});

+ 1 - 16
assets/javascripts/wizard/templates/application.hbs

@@ -1,16 +1 @@
-{{#if showCanvas}}
-  {{wizard-canvas}}
-{{/if}}
-
-<div class='wizard-column'>
-  <div class='wizard-column-contents'>
-    {{outlet}}
-  </div>
-  <div class='wizard-footer'>
-    {{#if customWizard}}
-      <img src="{{siteInfo.logo_small_url}}" style="background-image: initial; width: 33px; height: 33px;"/>
-    {{else}}
-      <div class='discourse-logo'></div>
-    {{/if}}
-  </div>
-</div>
+{{outlet}}

+ 0 - 1
assets/javascripts/wizard/templates/components/wizard-field-composer.hbs

@@ -1 +0,0 @@
-{{textarea elementId=field.id value=field.value placeholder=field.placeholder tabindex="9"}}

+ 16 - 0
assets/javascripts/wizard/templates/custom.hbs

@@ -0,0 +1,16 @@
+{{#if showCanvas}}
+  {{wizard-canvas}}
+{{/if}}
+
+<div class='wizard-column'>
+  <div class='wizard-column-contents'>
+    {{outlet}}
+  </div>
+  <div class='wizard-footer'>
+    {{#if customWizard}}
+      <img src="{{logoUrl}}" style="background-image: initial; width: 33px; height: 33px;"/>
+    {{else}}
+      <div class='discourse-logo'></div>
+    {{/if}}
+  </div>
+</div>

+ 3 - 0
assets/javascripts/wizard/templates/custom.index.hbs

@@ -0,0 +1,3 @@
+{{#if completed}}
+  {{i18n 'wizard.completed'}}
+{{/if}}

+ 9 - 1
assets/javascripts/wizard/templates/custom.step.hbs

@@ -1 +1,9 @@
-{{wizard-step step=step wizard=wizard goNext="goNext" goBack="goBack"}}
+<div class="step-message {{stepMessage.state}}">
+  {{stepMessage.text}}
+</div>
+{{wizard-step step=step
+              wizard=wizard
+              goNext="goNext"
+              goBack="goBack"
+              finished="finished"
+              showMessage="showMessage"}}

+ 89 - 0
assets/stylesheets/wizard/wizard_custom.scss

@@ -0,0 +1,89 @@
+.custom-wizard {
+  background-color: initial;
+
+  .wizard-step-description {
+    line-height: 1.7;
+  }
+
+  .wizard-column .wizard-step-banner {
+    width: initial;
+    max-width: 660px;
+  }
+
+  .control-group {
+    display: inline-block;
+    vertical-align: top;
+    margin-right: 20px;
+
+    .controls {
+      margin: 5px 0;
+    }
+
+    input {
+      width: 200px;
+      line-height: 24px;
+    }
+  }
+
+  .wizard-step-form .wizard-btn {
+    display: block;
+    margin: 10px 0;
+  }
+
+  .wizard-column .wizard-field .input-area {
+    margin: 0.5em 0;
+  }
+}
+
+.p-list-box {
+  max-width: 550px;
+  position: relative;
+  margin: 10px 0;
+
+  .spinner {
+    position: absolute;
+    right: 50%;
+    top: 50%;
+  }
+
+  .p-text {
+    margin-bottom: 5px;
+  }
+
+  ul {
+    border: 1px solid #e9e9e9;
+    padding: 0;
+    margin: 0;
+    list-style: none;
+    height: 95px;
+    overflow: scroll;
+  }
+
+  li {
+    padding: 6px 12px;
+    cursor: pointer;
+    background-color: #fff;
+
+    &:hover, &.selected {
+      background-color: #eee;
+    }
+
+    i {
+      margin-right: 5px;
+    }
+  }
+
+  .no-results {
+    padding: 15px;
+  }
+
+  .default {
+    margin: 0 auto;
+    top: 50%;
+    transform: translateY(-50%);
+    position: absolute;
+    width: 100%;
+    text-align: center;
+    color: #919191;
+  }
+}

+ 55 - 7
assets/stylesheets/custom_wizard.scss

@@ -13,15 +13,17 @@
 }
 
 .wizard-header {
-  font-size: 1.3em;
+  font-size: 1.4em;
   margin-bottom: 15px;
 
   &.medium {
-    font-size: 1.1em;
+    font-size: 1.2em;
   }
 
   &.small {
-    font-size: 0.97em;
+    font-size: 1em;
+    text-decoration: underline;
+    margin-bottom: 5px;
   }
 }
 
@@ -36,7 +38,7 @@
   .setting {
     display: inline-block;
     vertical-align: top;
-    min-width: 49%;
+    width: 49%;
 
     .setting-label {
       width: 90px;
@@ -49,6 +51,22 @@
     &.full {
       width: 100%;
     }
+
+    label {
+      margin: 5px 0;
+    }
+  }
+
+  .buttons .error {
+    color: $danger;
+
+    .fa {
+      margin-right: 5px;
+    }
+  }
+
+  .buttons .remove {
+    float: right;
   }
 }
 
@@ -60,19 +78,49 @@
   }
 }
 
+.wizard-column-contents {
+  position: relative;
+}
+
 .wizard-custom-step {
   display: inline-block;
-  width: 100%;
   margin-bottom: 20px;
   padding: 15px;
   background-color: dark-light-diff($primary, $secondary, 96%, -65%);
 }
 
+.step-message {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 0;
+  line-height: 0;
+  text-align: center;
+  transition: all .2s;
+
+  &.success {
+    height: 60px;
+    line-height: 60px;
+    background-color: $success;
+    color: $secondary;
+  }
+
+  &.error {
+    height: 60px;
+    line-height: 60px;
+    background-color: $danger;
+    color: $secondary;
+  }
+}
+
 .wizard-dropdown-choices {
-  margin-bottom: 25px;
+  padding: 15px 15px 0 15px;
+  margin-bottom: 20px;
+  background-color: $secondary;
 }
 
-.wizard-dropdown-choice {
+.setting .custom-input {
   display: inline-block;
 }
 

+ 31 - 15
config/locales/client.en.yml

@@ -12,38 +12,55 @@ en:
         background_placeholder: "Background css property"
         save_submissions: "Save"
         save_submissions_label: "Save wizard submissions"
+        multiple_submissions: "Multiple"
+        multiple_submissions_label: "Allow multiple submissions by the same user"
         save: "Save Changes"
         remove: "Delete Wizard"
         header: "Wizard"
         add: "Add"
         url: "Url"
-        translation: "Translation"
-
+        key: "Key"
+        id: "Id"
+        id_placeholder: "Underscored. Cannot be changed."
+        key_placeholder: "Translation key"
+        custom_text_placeholder: "Overrides translation"
+        type: "Type"
+        error:
+          name_required: "Wizards must have a name."
+          steps_required: "Wizards must have at least one step."
+          id_required: "All Step, Fields and Actions need an Id"
+          field:
+            need_choices: "All dropdowns need a translated choices, custom choies or preset choices."
+            choices_label_empty: "Custom choice labels cannot be empty."
         step:
           header: "Steps"
           title: "Title"
-          title_placeholder: "Overrides title translation"
           banner: "Banner"
           banner_placeholder: "Image url"
           description: "Description"
-          description_placeholder: "Overrides description translation"
-          translation_placeholder: "Translation key for step"
-
         field:
           header: "Fields"
           label: "Label"
           description: "Description"
-          type: "Type"
-          choices_label: "Dropdown Choices"
-          add_choice: "Add"
+          choices_label: "Dropdown Choices (use one type)"
+          choices_translation: "Translation"
+          choices_custom: "Custom"
+          choices_preset:
+            label: "Preset"
+            none: "Select a data type"
+            categories: "Categories"
+            filter: "Filter"
+            key: "Key"
+            value: "Value"
+          choice:
+            value: "Value"
+            label: "Label"
           required: "Required"
           required_label: "Field is Required"
-          translation_placeholder: "Translation key for field"
-
+          min_length: "Min Length"
+          min_length_placeholder: "Minimum length in characters"
         action:
           header: "Actions"
-          label: "Label"
-          type: "Type"
           send_message:
             label: "Send Message"
             title: "Title"
@@ -62,5 +79,4 @@ en:
 
   wizard_js:
     wizard:
-      validation:
-        too_short: "Post must be at least {{min}} characters"
+      completed: "You have completed this wizard."

+ 4 - 10
config/locales/server.en.yml

@@ -1,11 +1,5 @@
 en:
-  custom_wizard:
-    title: "Wizard"
-
-  new_wizard:
-    step_1:
-      title: "Translated title"
-      description: "Translated description"
-      field_1:
-        label: "Translated field title"
-        description: "Translated field description"
+  wizard:
+    custom_title: "Wizard"
+    field:
+      too_short: "%{label} must be at least %{min} characters"

+ 137 - 79
lib/builder.rb

@@ -1,12 +1,17 @@
 class CustomWizard::Builder
 
+  attr_accessor :wizard, :updater, :submission
+
   def initialize(user, wizard_id)
     data = PluginStore.get('custom_wizard', wizard_id)
     @custom_wizard = CustomWizard::Wizard.new(data)
-    @wizard = Wizard.new(user)
-    @wizard.id = wizard_id
-    @wizard.save_submissions = data['save_submissions']
-    @wizard.background = data["background"]
+    @wizard = Wizard.new(user,
+      id: wizard_id,
+      save_submissions: data['save_submissions'],
+      multiple_submissions: data['multiple_submissions'],
+      background: data["background"],
+      custom: true
+    )
   end
 
   def self.sorted_handlers
@@ -23,99 +28,152 @@ class CustomWizard::Builder
   end
 
   def build
-    @custom_wizard.steps.each do |s|
-      @wizard.append_step(s['id']) do |step|
-        step.title = s['title'] if s['title']
-        step.description = s['description'] if s['description']
-        step.banner = s['banner'] if s['banner']
-        step.translation_key = s['translation_key'] if s['translation_key']
-
-        s['fields'].each do |f|
-          params = {
-            id: f['id'],
-            type: f['type'],
-            required: f['required']
-          }
-
-          params[:label] = f['label'] if f['label']
-          params[:description] = f['description'] if f['description']
-          params[:translation_key] = f['translation_key'] if f['translation_key']
-
-          field = step.add_field(params)
-
-          if f['type'] == 'dropdown'
-            f['choices'].each do |c|
-              field.add_choice(c['id'], label: c['label'])
+    unless (@wizard.completed? && !@custom_wizard.respond_to?(:multiple_submissions)) ||
+           !@custom_wizard.steps
+      @custom_wizard.steps.each do |s|
+        @wizard.append_step(s['id']) do |step|
+          step.title = s['title'] if s['title']
+          step.description = s['description'] if s['description']
+          step.banner = s['banner'] if s['banner']
+          step.key = s['key'] if s['key']
+
+          if s['fields'] && s['fields'].length
+            s['fields'].each do |f|
+              params = {
+                id: f['id'],
+                type: f['type'],
+                required: f['required']
+              }
+
+              params[:label] = f['label'] if f['label']
+              params[:description] = f['description'] if f['description']
+              params[:key] = f['key'] if f['key']
+
+              submissions = Array.wrap(PluginStore.get("custom_wizard_submissions", @wizard.id))
+              if submissions.last && submissions.last['completed'] === false
+                @submission = submissions.last
+                params[:value] = @submission[f['id']] if @submission[f['id']]
+              end
+
+              field = step.add_field(params)
+
+              if f['type'] === 'dropdown'
+                if f['choices'] && f['choices'].length > 0
+                  f['choices'].each do |c|
+                    field.add_choice(c['value'], label: c['label'])
+                  end
+                elsif f['choices_key'] && f['choices_key'].length > 0
+                  choices = I18n.t(f['choices_key'])
+                  if choices.is_a?(Hash)
+                    choices.each do |k, v|
+                      field.add_choice(k, label: v)
+                    end
+                  end
+                elsif f['choices_preset'] && f['choices_preset'].length > 0
+                  objects = []
+
+                  if f['choices_preset'] === 'categories'
+                    objects = Site.new(Guardian.new(@wizard.user)).categories
+                  end
+
+                  if f['choices_filters'] && f['choices_filters'].length > 0
+                    f['choices_filters'].each do |f|
+                      objects.reject! { |o| o[f['key']] != f['value'] }
+                    end
+                  end
+
+                  if objects.length > 0
+                    objects.each do |o|
+                      field.add_choice(o.id, label: o.name)
+                    end
+                  end
+                end
+              end
             end
           end
-        end
 
-        step.on_update do |updater|
-
-          @updater = updater
-          input = updater.fields
-          user = @wizard.user
-
-          if @wizard.save_submissions && input
-            store_key = @wizard.id
-            submissions = Array.wrap(PluginStore.get("custom_wizard_submissions", store_key))
-            submission = {}
-
-            if submissions.last && submissions.last['completed'] === false
-              submission = submissions.last
-              submissions.pop(1)
+          step.on_update do |updater|
+            @updater = updater
+            input = updater.fields
+            user = @wizard.user
+
+            if s['fields'] && s['fields'].length
+              s['fields'].each do |f|
+                value = input[f['id']]
+                min_length = f['min_length']
+                if min_length && value.is_a?(String) && value.length < min_length.to_i
+                  label = f['label'] || I18n.t("#{f['key']}.label")
+                  updater.errors.add(f['id'].to_s, I18n.t('wizard.field.too_short', label: label, min: min_length.to_i))
+                end
+              end
             end
 
-            submission['user_id'] = @wizard.user.id
-            submission['completed'] = updater.step.next.nil?
+            next if updater.errors.any?
 
-            input.each do |key, value|
-              submission[key] = value
+            CustomWizard::Builder.step_handlers.each do |handler|
+              if handler[:wizard_id] == @wizard.id
+                handler[:block].call(self)
+              end
             end
 
-            submissions.push(submission)
+            next if updater.errors.any?
+
+            if s['actions'] && s['actions'].length
+              s['actions'].each do |a|
+                if a['type'] === 'create_topic'
+                  creator = PostCreator.new(user,
+                                  title: input[a['title']],
+                                  raw: input[a['post']],
+                                  category: a['category_id'],
+                                  skip_validations: true)
+
+                  post = creator.create
+                  if creator.errors.present?
+                    updater.errors.add(:create_topic, creator.errors.full_messages.join(" "))
+                  else
+                    updater.result = { topic_id: post.topic.id }
+                  end
+                end
 
-            PluginStore.set('custom_wizard_submissions', store_key, submissions)
-          end
+                if a['type'] === 'send_message'
+                  creator = PostCreator.new(user,
+                                  title: input[a['title']],
+                                  raw: input[a['post']],
+                                  archetype: Archetype.private_message,
+                                  target_usernames: a['username'])
 
-          if s['actions'] && s['actions'].length
-            s['actions'].each do |a|
-              if a['type'] === 'create_topic'
-                creator = PostCreator.new(user,
-                                title: input[a['title']],
-                                raw: input[a['post']],
-                                category: a['category_id'],
-                                skip_validations: true)
-
-                post = creator.create
-                if creator.errors.present?
-                  raise StandardError, creator.errors.full_messages.join(" ")
-                end
+                  post = creator.create
 
-                updater.result = { topic_id: post.topic.id }
+                  if creator.errors.present?
+                    updater.errors.add(:send_message, creator.errors.full_messages.join(" "))
+                  else
+                    updater.result = { topic_id: post.topic.id }
+                  end
+                end
               end
+            end
 
-              if a['type'] === 'send_message'
-                creator = PostCreator.new(user,
-                                title: input[a['title']],
-                                raw: input[a['post']],
-                                archetype: Archetype.private_message,
-                                target_usernames: a['username'])
+            if @wizard.save_submissions && updater.errors.empty?
+              store_key = @wizard.id
+              submissions = Array.wrap(PluginStore.get("custom_wizard_submissions", store_key))
+              submission = {}
 
-                post = creator.create
+              if submissions.last && submissions.last['completed'] === false
+                submission = submissions.last
+                submissions.pop(1)
+              end
 
-                if creator.errors.present?
-                  raise StandardError, creator.errors.full_messages.join(" ")
-                end
+              submission['user_id'] = @wizard.user.id
+              submission['completed'] = updater.step.next.nil?
 
-                updater.result = { topic_id: post.topic.id }
+              if input
+                input.each do |key, value|
+                  submission[key] = value
+                end
               end
-            end
-          end
 
-          CustomWizard::Builder.step_handlers.each do |handler|
-            if handler[:wizard_id] == @wizard.id
-              handler[:block].call(self)
+              submissions.push(submission)
+              PluginStore.set('custom_wizard_submissions', store_key, submissions)
             end
           end
         end

+ 1 - 1
lib/field.rb

@@ -1,6 +1,6 @@
 class CustomWizard::Field
   def self.types
-    @types ||= ['dropdown', 'image', 'radio', 'text', 'textarea', 'composer']
+    @types ||= ['dropdown', 'image', 'radio', 'text', 'textarea']
   end
 
   def self.require_assets

+ 2 - 1
lib/wizard.rb

@@ -1,6 +1,6 @@
 class CustomWizard::Wizard
 
-  attr_reader :id, :name, :steps, :background, :save_submissions, :custom
+  attr_reader :id, :name, :steps, :background, :save_submissions, :multiple_submissions, :custom
 
   def initialize(data)
     data = data.is_a?(String) ? ::JSON.parse(data) : data
@@ -8,6 +8,7 @@ class CustomWizard::Wizard
     @name = data['name']
     @background = data['background']
     @save_submissions = data['save_submissions']
+    @multiple_submissions = data['multiple_submissions']
     @steps = data['steps']
     @custom = true
   end

+ 86 - 22
plugin.rb

@@ -3,10 +3,11 @@
 # version: 0.1
 # authors: Angus McLeod
 
-register_asset 'stylesheets/custom_wizard.scss'
+register_asset 'stylesheets/wizard_custom_admin.scss'
 
 config = Rails.application.config
 config.assets.paths << Rails.root.join("plugins", "discourse-custom-wizard", "assets", "javascripts")
+config.assets.paths << Rails.root.join("plugins", "discourse-custom-wizard", "assets", "stylesheets", "wizard")
 
 after_initialize do
   require_dependency "application_controller"
@@ -33,9 +34,7 @@ after_initialize do
 
   require_dependency 'admin_constraint'
   Discourse::Application.routes.append do
-    namespace :wizard do
-      mount ::CustomWizard::Engine, at: 'custom'
-    end
+    mount ::CustomWizard::Engine, at: 'w'
 
     scope module: 'custom_wizard', constraints: AdminConstraint.new do
       get 'admin/wizards' => 'admin#index'
@@ -52,21 +51,61 @@ after_initialize do
     end
   end
 
-  class ::Wizard
-    attr_accessor :id, :background, :save_submissions
-  end
+  require_dependency 'wizard'
+  require_dependency 'wizard/step'
+  require_dependency 'wizard/step_updater'
+  require_dependency 'wizard/field'
 
-  class ::Wizard::Step
-    attr_accessor :title, :description, :translation_key
-  end
+  ::Wizard.class_eval do
+    attr_accessor :id, :background, :save_submissions, :multiple_submissions
+
+    def initialize(user, attrs = {})
+      @steps = []
+      @user = user
+      @first_step = nil
+      @max_topics_to_require_completion = 15
+      @id = attrs[:id] if attrs[:id]
+      @save_submissions = attrs[:save_submissions] if attrs[:save_submissions]
+      @multiple_submissions = attrs[:multiple_submissions] if attrs[:multiple_submissions]
+      @background = attrs[:background] if attrs[:background]
+      @custom = attrs[:custom] if attrs[:custom]
+    end
 
-  class ::Wizard::StepUpdater
-    attr_accessor :result, :step
+    def completed?
+      completed_steps?(@steps.map(&:id))
+    end
+
+    def completed_steps?(steps)
+      steps = [steps].flatten.uniq
+
+      completed = UserHistory.where(
+        acting_user_id: @user.id,
+        action: UserHistory.actions[:wizard_step]
+      ).where(context: steps)
+        .distinct.order(:context).pluck(:context)
+
+      steps.sort == completed
+    end
+
+    def start
+      completed = UserHistory.where(
+        acting_user_id: @user.id,
+        action: UserHistory.actions[:wizard_step]
+      ).where(context: @steps.map(&:id))
+        .uniq.pluck(:context)
+
+      # First uncompleted step
+      steps = @custom ? @steps : steps_with_fields
+      steps.each do |s|
+        return s unless completed.include?(s.id)
+      end
+
+      @first_step
+    end
   end
 
-  require_dependency 'wizard/field'
-  Wizard::Field.class_eval do
-    attr_reader :label, :description, :translation_key
+  ::Wizard::Field.class_eval do
+    attr_reader :label, :description, :key, :min_length
 
     def initialize(attrs)
       attrs = attrs || {}
@@ -76,14 +115,23 @@ after_initialize do
       @required = !!attrs[:required]
       @label = attrs[:label]
       @description = attrs[:description]
-      @translation_key = attrs[:translation_key]
+      @key = attrs[:key]
+      @min_length = attrs[:min_length]
       @value = attrs[:value]
       @choices = []
     end
   end
 
+  class ::Wizard::Step
+    attr_accessor :title, :description, :key
+  end
+
+  class ::Wizard::StepUpdater
+    attr_accessor :result, :step
+  end
+
   ::WizardSerializer.class_eval do
-    attributes :id, :background
+    attributes :id, :background, :completed
 
     def id
       object.id
@@ -93,32 +141,48 @@ after_initialize do
       object.background
     end
 
+    def completed
+      object.completed?
+    end
+
+    def include_completed?
+      object.completed? && !object.multiple_submissions && !scope.current_user.admin?
+    end
+
     def include_start?
-      object.start
+      object.start && include_steps?
+    end
+
+    def include_steps?
+      !include_completed?
     end
   end
 
   ::WizardStepSerializer.class_eval do
     def title
       return object.title if object.title
-      I18n.t("#{object.translation_key || i18n_key}.title", default: '')
+      I18n.t("#{object.key || i18n_key}.title", default: '')
     end
 
     def description
       return object.description if object.description
-      I18n.t("#{object.translation_key || i18n_key}.description", default: '')
+      I18n.t("#{object.key || i18n_key}.description", default: '')
     end
   end
 
   ::WizardFieldSerializer.class_eval do
     def label
       return object.label if object.label
-      I18n.t("#{object.translation_key || i18n_key}.label", default: '')
+      I18n.t("#{object.key || i18n_key}.label", default: '')
     end
 
     def description
       return object.description if object.description
-      I18n.t("#{object.translation_key || i18n_key}.description", default: '')
+      I18n.t("#{object.key || i18n_key}.description", default: '')
+    end
+
+    def placeholder
+      I18n.t("#{object.key || i18n_key}.placeholder", default: '')
     end
   end
 end

+ 0 - 12
public/desktop.css

@@ -1,12 +0,0 @@
-.custom-wizard {
-  background-color: initial;
-}
-
-.custom-wizard .wizard-step-description {
-  line-height: 1.7;
-}
-
-.custom-wizard .wizard-column .wizard-step-banner {
-  width: initial;
-  max-width: 660px;
-}