CAPSTONE 3

Multi-Step Form Wizard

Build a robust form wizard with StackContainer navigation, per-step validation guards, dojo/store draft persistence, a custom progress indicator widget, and a Deferred final submit with success Dialog

StackContainer declare dojo/store Deferred Per-Step Validation Dialog
New Employee Onboarding Step 2 of 4
Personal
2
Employment
3
Access
4
Review
1

Architecture Overview

The wizard is a self-contained Dojo module. It owns its layout (StackContainer), its state (dojo/store/Memory draft), and its navigation logic (per-step guard functions). The parent app only calls wizard.show() and listens for the WIZARD_COMPLETED topic.

Module graph
OnboardingWizard // top-level declare widget ├── StackContainer // step panes │ ├── Step1Personal // ContentPane + Form │ ├── Step2Employment // ContentPane + Form │ ├── Step3Access // ContentPane + CheckBox group │ └── Step4Review // ContentPane — read-only summary ├── ProgressIndicator // custom declare widget ├── DraftStore // dojo/store/Memory — single record └── SubmitService // returns Deferred
State machine
idle ──show()──▶ step-1 ──next() + valid──▶ step-2 ──next() + valid──▶ step-3 ──next() + valid──▶ step-4 (review) ──submit()──▶ submitting ──resolve──▶ success ──reject──▶ error (step-4) ──back()──▶ prev step (always allowed, no guard) ──cancel()──▶ confirm dialog ──▶ idle
ConcernHandled byNotes
NavigationOnboardingWizardCalls guard fn before advancing
ValidationPer-step Form + guardReturns boolean; highlights invalid fields
Draft persistenceDraftStore moduleWritten on every "next", read on "back"
Visual progressProgressIndicator widgetCustom declare; prop-based rendering
Submit I/OSubmitServiceReturns Deferred; wizard awaits resolution
Completion signaltopic.publishParent subscribes; wizard doesn't know parent
2

StackContainer Step Navigation

dijit/layout/StackContainer shows exactly one child pane at a time. The wizard calls selectChild() to advance or go back. Tabs are hidden so only the custom ProgressIndicator and Next/Back buttons drive navigation.

Wizard skeleton
// app/wizard/OnboardingWizard.js
define([
  "dojo/_base/declare",
  "dojo/_base/lang",
  "dijit/_WidgetBase",
  "dijit/layout/StackContainer",
  "dijit/layout/ContentPane",
  "dojo/dom-construct",
  "dojo/topic",
  "app/wizard/ProgressIndicator",
  "app/wizard/DraftStore",
  "app/wizard/SubmitService",
  "app/wizard/steps/Step1Personal",
  "app/wizard/steps/Step2Employment",
  "app/wizard/steps/Step3Access",
  "app/wizard/steps/Step4Review",
  "app/topics"
], function(declare, lang, _WidgetBase, StackContainer, ContentPane,
            domConstruct, topic, ProgressIndicator, DraftStore,
            SubmitService, Step1, Step2, Step3, Step4, topics) {

  return declare([_WidgetBase], {

    // Current step index (0-based)
    _step: 0,
    _steps: null,    // array of { pane, widget, guard }

    postCreate: function() {
      this._draft = DraftStore.create();

      // Progress indicator
      this._progress = new ProgressIndicator({
        labels: ["Personal", "Employment", "Access", "Review"],
        current: 0
      });
      domConstruct.place(this._progress.domNode, this.domNode);

      // StackContainer — no tab strip
      this._stack = new StackContainer({
        style: "width:100%;min-height:340px;"
      });
      domConstruct.place(this._stack.domNode, this.domNode);

      // Build steps
      var s1 = new Step1({ draft: this._draft });
      var s2 = new Step2({ draft: this._draft });
      var s3 = new Step3({ draft: this._draft });
      var s4 = new Step4({ draft: this._draft });

      this._steps = [
        { widget: s1, pane: this._wrapPane("step-1", s1) },
        { widget: s2, pane: this._wrapPane("step-2", s2) },
        { widget: s3, pane: this._wrapPane("step-3", s3) },
        { widget: s4, pane: this._wrapPane("step-4", s4) }
      ];

      this._steps.forEach(lang.hitch(this, function(s) {
        this._stack.addChild(s.pane);
      }));

      // Navigation buttons
      this._buildNavButtons();
      this._stack.startup();
      this._updateNav();
    },

    _wrapPane: function(id, widget) {
      var pane = new ContentPane({ id: id });
      domConstruct.place(widget.domNode, pane.containerNode);
      return pane;
    },

    _buildNavButtons: function() {
      var nav = domConstruct.create("div", {
        style: "display:flex;justify-content:space-between;align-items:center;padding:.8rem 0"
      }, this.domNode);

      this._backBtn = domConstruct.create("button", {
        textContent: "�? Back",
        className: "wbtn secondary",
        onclick: lang.hitch(this, "_goBack")
      }, nav);

      this._draftMsg = domConstruct.create("span", {
        style: "font-size:.75rem;color:var(--muted)"
      }, nav);

      this._nextBtn = domConstruct.create("button", {
        textContent: "Next →",
        className: "wbtn primary",
        onclick: lang.hitch(this, "_goNext")
      }, nav);
    },

    startup: function() {
      this.inherited(arguments);
      this._steps.forEach(function(s) { s.widget.startup(); });
    }
  });
});
selectChild — advance and retreat
// Navigation methods — added to OnboardingWizard
_goNext: function() {
  var current = this._steps[this._step];
  if (!current.widget.validate()) {
    return;   // guard failed — stay on current step
  }
  current.widget.saveDraft();   // persist this step's values

  this._step++;
  if (this._step === this._steps.length - 1) {
    // Entering review step — render summary
    this._steps[this._step].widget.render();
    this._nextBtn.textContent = "Submit";
    this._nextBtn.onclick = lang.hitch(this, "_submit");
  }
  this._stack.selectChild(this._steps[this._step].pane);
  this._progress.set("current", this._step);
  this._updateNav();
},

_goBack: function() {
  if (this._step === 0) { return; }
  this._step--;
  this._stack.selectChild(this._steps[this._step].pane);
  this._steps[this._step].widget.restoreFromDraft();
  this._progress.set("current", this._step);
  this._updateNav();
},

_updateNav: function() {
  this._backBtn.disabled = (this._step === 0);
  if (this._step < this._steps.length - 1) {
    this._nextBtn.textContent = "Next →";
  }
}
WHY no StackContainer tabs

StackContainer shows a tab strip by default (tabPosition: "none" hides it). Wizards need a linear flow with validation gates — tab strips let users skip steps freely. Always hide tabs and drive navigation through your own buttons so the guard logic cannot be bypassed.

3

Per-Step Form Validation Before Advance

Each step widget exposes a validate() method. The wizard calls it before selectChild(). If validation fails the step widget highlights its invalid fields and returns false — the wizard stays put.

Step base mixin — shared validate/save/restore contract
// app/wizard/steps/WizardStepMixin.js
define(["dojo/_base/declare"], function(declare) {
  return declare([], {
    // Override in each step
    validate:    function() { return true; },
    saveDraft:   function() {},
    restoreFromDraft: function() {},
    render:      function() {}   // for review step
  });
});
Step 1 — Personal Info
// app/wizard/steps/Step1Personal.js
define([
  "dojo/_base/declare",
  "dijit/_WidgetBase",
  "dijit/form/ValidationTextBox",
  "dijit/form/DateTextBox",
  "dojo/dom-construct",
  "app/wizard/steps/WizardStepMixin"
], function(declare, _WidgetBase, ValidationTextBox, DateTextBox,
            domConstruct, WizardStepMixin) {

  return declare([_WidgetBase, WizardStepMixin], {

    draft: null,  // injected by wizard

    postCreate: function() {
      this._name = new ValidationTextBox({
        name: "name",
        placeHolder: "Full name",
        required: true,
        style: "width:100%;margin-bottom:.8rem;"
      });

      this._email = new ValidationTextBox({
        name: "email",
        placeHolder: "Work email",
        required: true,
        regExp: ".+@.+\\..+",
        invalidMessage: "Enter a valid email address",
        style: "width:100%;margin-bottom:.8rem;"
      });

      this._phone = new ValidationTextBox({
        name: "phone",
        placeHolder: "Mobile number",
        required: true,
        regExp: "\\d{10}",
        invalidMessage: "10-digit number required",
        style: "width:100%;margin-bottom:.8rem;"
      });

      domConstruct.place(this._name.domNode,  this.domNode);
      domConstruct.place(this._email.domNode, this.domNode);
      domConstruct.place(this._phone.domNode, this.domNode);
    },

    startup: function() {
      this.inherited(arguments);
      this._name.startup();
      this._email.startup();
      this._phone.startup();
    },

    // Called by wizard before advancing
    validate: function() {
      var valid = this._name.isValid()
               && this._email.isValid()
               && this._phone.isValid();

      if (!valid) {
        // Force display of validation errors
        this._name.validate(true);
        this._email.validate(true);
        this._phone.validate(true);
      }
      return valid;
    },

    // Persist current values into the draft store
    saveDraft: function() {
      this.draft.put({
        id: "draft",
        name:  this._name.get("value"),
        email: this._email.get("value"),
        phone: this._phone.get("value")
      });
    },

    // Restore from draft (called on "Back")
    restoreFromDraft: function() {
      var d = this.draft.get("draft");
      if (d) {
        this._name.set("value",  d.name  || "");
        this._email.set("value", d.email || "");
        this._phone.set("value", d.phone || "");
      }
    }
  });
});
GOTCHA — validate(true) vs isValid()

widget.isValid() checks the current value but does NOT show the red border or error tooltip. You must call widget.validate(true) (passing true for "isFocused") to trigger the visible error state. Call isValid() to aggregate results, then call validate(true) on each invalid widget to make errors visible to the user.

Step 2 — Employment Info (with Select)
// app/wizard/steps/Step2Employment.js (condensed)
define([
  "dojo/_base/declare",
  "dijit/_WidgetBase",
  "dijit/form/ValidationTextBox",
  "dijit/form/Select",
  "dijit/form/DateTextBox",
  "dojo/dom-construct",
  "app/wizard/steps/WizardStepMixin"
], function(declare, _WidgetBase, ValidationTextBox, Select, DateTextBox,
            domConstruct, WizardStepMixin) {

  return declare([_WidgetBase, WizardStepMixin], {
    draft: null,

    postCreate: function() {
      this._dept = new Select({
        name: "dept",
        options: ["Engineering","DevOps","HR","Marketing","Finance"]
          .map(function(d) { return { value:d, label:d }; }),
        style: "width:100%;margin-bottom:.8rem;"
      });

      this._role = new ValidationTextBox({
        name: "role", placeHolder: "Job title", required: true,
        style: "width:100%;margin-bottom:.8rem;"
      });

      this._salary = new ValidationTextBox({
        name: "salary", placeHolder: "Annual salary (INR)",
        required: true, regExp: "\\d+",
        invalidMessage: "Numbers only",
        style: "width:100%;margin-bottom:.8rem;"
      });

      this._startDate = new DateTextBox({
        name: "startDate", required: true,
        constraints: { min: new Date() },
        style: "width:100%;margin-bottom:.8rem;"
      });

      [this._dept, this._role, this._salary, this._startDate].forEach(
        function(w) { domConstruct.place(w.domNode, this.domNode); }, this);
    },

    validate: function() {
      var valid = this._role.isValid() && this._salary.isValid() && this._startDate.isValid();
      if (!valid) {
        this._role.validate(true);
        this._salary.validate(true);
        this._startDate.validate(true);
      }
      return valid;
    },

    saveDraft: function() {
      var d = this.draft.get("draft") || { id:"draft" };
      d.dept      = this._dept.get("value");
      d.role      = this._role.get("value");
      d.salary    = parseInt(this._salary.get("value"), 10);
      d.startDate = this._startDate.get("value");
      this.draft.put(d);
    },

    restoreFromDraft: function() {
      var d = this.draft.get("draft");
      if (!d) { return; }
      this._dept.set("value",      d.dept      || "Engineering");
      this._role.set("value",      d.role      || "");
      this._salary.set("value",    String(d.salary || ""));
      this._startDate.set("value", d.startDate  || null);
    }
  });
});
Step 3 — System Access (CheckBox group)
// Step3Access — array of CheckBox widgets
define([
  "dojo/_base/declare",
  "dijit/_WidgetBase",
  "dijit/form/CheckBox",
  "dojo/dom-construct",
  "app/wizard/steps/WizardStepMixin"
], function(declare, _WidgetBase, CheckBox, domConstruct, WizardStepMixin) {

  var ACCESS_LEVELS = ["VPN", "Source Control", "JIRA", "Confluence", "AWS Console"];

  return declare([_WidgetBase, WizardStepMixin], {
    draft: null,
    _boxes: null,

    postCreate: function() {
      this._boxes = ACCESS_LEVELS.map(function(name) {
        var cb = new CheckBox({ value: name });
        var row = domConstruct.create("div", {
          style: "display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem;"
        }, this.domNode);
        domConstruct.place(cb.domNode, row);
        domConstruct.create("label", { textContent: name }, row);
        return { cb: cb, name: name };
      }, this);
    },

    // Access step is always valid (at least 0 items is fine)
    validate: function() { return true; },

    saveDraft: function() {
      var d = this.draft.get("draft") || { id:"draft" };
      d.access = this._boxes
        .filter(function(item) { return item.cb.get("checked"); })
        .map(function(item) { return item.name; });
      this.draft.put(d);
    },

    restoreFromDraft: function() {
      var d = this.draft.get("draft");
      var granted = (d && d.access) || [];
      this._boxes.forEach(function(item) {
        item.cb.set("checked", granted.indexOf(item.name) >= 0);
      });
    }
  });
});
4

dojo/store Draft Persistence

Draft data lives in a dojo/store/Memory store keyed to "draft". Every "Next" writes the current step; every "Back" reads from it. This makes forward/back navigation safe — the user never loses input by clicking Back.

DraftStore module
// app/wizard/DraftStore.js
define(["dojo/store/Memory"], function(Memory) {

  return {
    create: function() {
      return new Memory({
        idProperty: "id",
        data: [{ id: "draft" }]   // pre-seed with an empty record
      });
    }
  };
});
Draft read/write API
// Writing (called in saveDraft()):
var current = draft.get("draft") || { id: "draft" };
current.name = "Priya Sharma";
draft.put(current);   // updates existing record

// Reading (called in restoreFromDraft()):
var d = draft.get("draft");
if (d && d.name) {
  nameBox.set("value", d.name);
}
Final draft snapshot for submit
// Before calling SubmitService, read the complete draft
var payload = draft.get("draft");
// payload = {
//   id: "draft",
//   name: "Priya Sharma", email: "priya@techoral.com", phone: "9876543210",
//   dept: "Engineering", role: "Senior Dev", salary: 92000,
//   startDate: Date, access: ["VPN", "Source Control", "JIRA"]
// }
delete payload.id;   // remove store meta key before sending to API
PERSISTENCE SCOPE

This in-memory store survives Back/Next navigation within a single wizard session. It does not survive page refresh. For multi-session draft persistence, serialize the store to localStorage after each put() and rehydrate it in DraftStore.create():

// Persist to localStorage on write
var origPut = draft.put.bind(draft);
draft.put = function(obj) {
  var result = origPut(obj);
  localStorage.setItem("wizardDraft", JSON.stringify(obj));
  return result;
};

// Rehydrate on create
var saved = localStorage.getItem("wizardDraft");
var initial = saved ? JSON.parse(saved) : { id:"draft" };
initial.id = "draft";
return new Memory({ idProperty:"id", data:[initial] });
Clear draft on success or cancel
// On successful submit
draft.remove("draft");
localStorage.removeItem("wizardDraft");

// On cancel — confirm first
var dlg = new ConfirmDialog({ message: "Discard draft and exit?" });
dlg.on("execute", function() {
  draft.remove("draft");
  localStorage.removeItem("wizardDraft");
  topic.publish(topics.WIZARD_CANCELLED);
});
5

Progress Indicator Widget (custom declare)

The ProgressIndicator is a pure declare widget — no _TemplatedMixin needed. It accepts labels (array of step names) and current (0-based index), renders step circles, and reacts to set("current", n) via a _setCurrentAttr setter.

ProgressIndicator implementation
// app/wizard/ProgressIndicator.js
define([
  "dojo/_base/declare",
  "dojo/_base/lang",
  "dijit/_WidgetBase",
  "dojo/dom-construct",
  "dojo/dom-class",
  "dojo/dom-style"
], function(declare, lang, _WidgetBase, domConstruct, domClass, domStyle) {

  return declare([_WidgetBase], {

    // Widget properties
    labels:  [],   // ["Personal", "Employment", "Access", "Review"]
    current: 0,    // 0-based index

    postCreate: function() {
      domStyle.set(this.domNode, {
        display:        "flex",
        alignItems:     "center",
        justifyContent: "center",
        padding:        "1rem 0",
        gap:            "0"
      });
      this._circles = [];
      this._lines   = [];
      this._render();
    },

    _render: function() {
      domConstruct.empty(this.domNode);
      this._circles = [];
      this._lines   = [];

      this.labels.forEach(lang.hitch(this, function(label, i) {
        var step = domConstruct.create("div", {
          style: "display:flex;flex-direction:column;align-items:center;flex:1;position:relative;"
        }, this.domNode);

        // Connector line (not on last step)
        if (i < this.labels.length - 1) {
          var line = domConstruct.create("div", {
            style: "position:absolute;top:15px;left:50%;width:100%;height:2px;background:var(--border);z-index:0;"
          }, step);
          this._lines.push({ el: line, index: i });
        }

        // Step circle
        var circle = domConstruct.create("div", {
          style: "width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.78rem;font-weight:700;z-index:1;border:2px solid var(--border);color:var(--muted);background:var(--surface);",
          textContent: i < this.current ? "✓" : String(i + 1)
        }, step);
        this._circles.push({ el: circle, index: i });

        // Label
        domConstruct.create("div", {
          textContent: label,
          style: "font-size:.7rem;color:var(--muted);margin-top:.3rem;white-space:nowrap;"
        }, step);

        // Apply initial state
        this._applyState(circle, i);
      }));

      // Color lines up to current
      this._lines.forEach(lang.hitch(this, function(item) {
        if (item.index < this.current) {
          item.el.style.background = "var(--purple)";
        }
      }));
    },

    _applyState: function(circle, index) {
      if (index < this.current) {
        // Completed
        circle.style.background    = "linear-gradient(135deg,#6366f1,#22d3ee)";
        circle.style.borderColor   = "transparent";
        circle.style.color         = "#fff";
      } else if (index === this.current) {
        // Active
        circle.style.borderColor   = "var(--cyan)";
        circle.style.color         = "var(--cyan)";
      }
      // Future steps: default styles already set
    },

    // Called when wizard does: indicator.set("current", newIndex)
    _setCurrentAttr: function(value) {
      this._set("current", value);
      this._render();   // full re-render — small DOM, acceptable cost
    }
  });
});
_setXxxAttr pattern

Naming the setter _setCurrentAttr (matching the property name current) lets Dijit's _WidgetBase.set() dispatch to it automatically. When the wizard calls indicator.set("current", 2), Dijit calls _setCurrentAttr(2) — you don't need to wire this manually. Always call this._set("propertyName", value) inside the setter to actually update the stored value.

Usage in wizard
// Create once in postCreate
this._progress = new ProgressIndicator({
  labels:  ["Personal", "Employment", "Access", "Review"],
  current: 0
});
domConstruct.place(this._progress.domNode, this.domNode, "first");

// Update on step change
this._progress.set("current", this._step);   // triggers _setCurrentAttr
6

Final Submit with Deferred + Success Dialog

The Submit button calls SubmitService.submit(payload) which returns a Deferred. While pending the button shows "Saving…" and is disabled. On resolve a success Dialog appears and publishes WIZARD_COMPLETED. On reject the error is shown inline on the review step without losing any data.

SubmitService module
// app/wizard/SubmitService.js
define(["dojo/Deferred", "dojo/request/xhr"], function(Deferred, xhr) {

  return {
    submit: function(payload) {
      // Real implementation: return xhr.post("/api/employees", { data: JSON.stringify(payload), ... });
      // For demo: simulate network delay + random 10% error
      var def = new Deferred();
      setTimeout(function() {
        if (Math.random() < 0.1) {
          def.reject({ message: "Server error — please try again." });
        } else {
          def.resolve({ id: Date.now(), status: "created" });
        }
      }, 1200);
      return def.promise;
    }
  };
});
Submit handler in wizard
_submit: function() {
  var payload = this._draft.get("draft");
  if (!payload) { return; }

  // Disable button, show loading state
  this._nextBtn.disabled    = true;
  this._nextBtn.textContent = "Saving…";
  this._errorNode && domConstruct.destroy(this._errorNode);

  SubmitService.submit(payload)
    .then(lang.hitch(this, function(result) {
      // Success — show Dialog, publish topic, clear draft
      this._draft.remove("draft");
      localStorage.removeItem("wizardDraft");

      var dlg = new Dialog({
        title: "Onboarding Complete",
        content: "<p style='color:#34d399;font-size:1.1rem'>✓ Employee created successfully!</p>"
                + "<p>Employee ID: <strong>" + result.id + "</strong></p>",
        style: "width:320px"
      });
      dlg.on("hide", function() { dlg.destroyRecursive(); });
      dlg.startup();
      dlg.show();

      topic.publish(topics.WIZARD_COMPLETED, { result: result });
    }))
    .otherwise(lang.hitch(this, function(err) {
      // Error — stay on review step, show message
      this._nextBtn.disabled    = false;
      this._nextBtn.textContent = "Retry Submit";
      this._errorNode = domConstruct.create("div", {
        innerHTML: "⚠ " + (err.message || "Submit failed"),
        style:     "color:#f87171;padding:.5rem 0;font-size:.85rem;"
      }, this._nextBtn.parentNode, "before");
    }));
},
Step 4 Review — render summary from draft
// app/wizard/steps/Step4Review.js
define([
  "dojo/_base/declare",
  "dijit/_WidgetBase",
  "dojo/dom-construct",
  "app/wizard/steps/WizardStepMixin"
], function(declare, _WidgetBase, domConstruct, WizardStepMixin) {

  return declare([_WidgetBase, WizardStepMixin], {
    draft: null,

    // render() is called by wizard just before showing step 4
    render: function() {
      domConstruct.empty(this.domNode);
      var d = this.draft.get("draft");
      if (!d) { return; }

      var fields = [
        { label: "Name",        value: d.name },
        { label: "Email",       value: d.email },
        { label: "Phone",       value: d.phone },
        { label: "Department",  value: d.dept },
        { label: "Role",        value: d.role },
        { label: "Salary",      value: "₹" + (d.salary || 0).toLocaleString() },
        { label: "Start Date",  value: d.startDate ? d.startDate.toLocaleDateString() : "—" },
        { label: "Access",      value: (d.access || []).join(", ") || "None" }
      ];

      var html = "<h5 style='color:var(--cyan);margin-top:0'>Review & Confirm</h5>";
      fields.forEach(function(f) {
        html += "<div style='display:flex;justify-content:space-between;padding:.4rem 0;"
              + "border-bottom:1px solid rgba(99,102,241,.1);font-size:.85rem'>"
              + "<span style='color:var(--muted)'>" + f.label + "</span>"
              + "<span style='color:var(--text);font-weight:600'>" + f.value + "</span>"
              + "</div>";
      });
      this.domNode.innerHTML = html;
    },

    // Review is read-only — always valid
    validate: function() { return true; }
  });
});
PATTERN — decouple result notification

The wizard publishes WIZARD_COMPLETED after the success Dialog is shown. The parent app subscribes and can refresh a grid, navigate away, or update a counter — without the wizard knowing anything about the parent. This is the same pub/sub decoupling from Phase 6, applied at the macro-composition level.

7

Full Wiring — Bootstrap & Integration

The wizard integrates into any parent app with three lines. Here's the complete main.js that puts the wizard inside a Dialog so it can be opened from an "Onboard New Employee" button.

main.js
var dojoConfig = {
  async: true,
  parseOnLoad: false,
  packages: [{ name:"app", location:"/js/app" }]
};

require([
  "dojo/dom",
  "dojo/on",
  "dojo/topic",
  "dijit/Dialog",
  "app/wizard/OnboardingWizard",
  "app/topics",
  "dojo/domReady!"
], function(dom, on, topic, Dialog, OnboardingWizard, topics) {

  var wizardDlg = null;

  on(dom.byId("openWizardBtn"), "click", function() {
    // Each click creates a fresh wizard + dialog
    var wizard = new OnboardingWizard({});
    wizard.buildRendering();
    wizard.postCreate();

    wizardDlg = new Dialog({
      title: "New Employee Onboarding",
      content: wizard.domNode,
      style: "width:560px",
      onHide: function() {
        wizardDlg.destroyRecursive();
        wizardDlg = null;
      }
    });
    wizardDlg.startup();
    wizard.startup();
    wizardDlg.show();
  });

  // React when wizard completes
  topic.subscribe(topics.WIZARD_COMPLETED, function(evt) {
    console.log("New employee onboarded:", evt.result.id);
    // Refresh HR Dashboard employee grid here
    topic.publish("hr/employee/refresh");
  });

  // React when wizard is cancelled
  topic.subscribe(topics.WIZARD_CANCELLED, function() {
    if (wizardDlg) { wizardDlg.hide(); }
  });
});
HTML scaffold
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/dojo/dijit/themes/claro/claro.css">
  <script>/* dojoConfig above */</script>
  <script src="/dojo/dojo/dojo.js"></script>
</head>
<body class="claro">
  <button id="openWizardBtn">Onboard New Employee</button>
  <script src="/js/main.js"></script>
</body>
</html>
Complete module checklist
FilePurposeKey API
OnboardingWizard.jsRoot widget — navigation, state machinedeclare, StackContainer, own()
ProgressIndicator.jsStep tracker displaydeclare, _setCurrentAttr
DraftStore.jsIn-memory draft with optional localStoragedojo/store/Memory
SubmitService.jsXHR submit — returns DeferredDeferred, xhr.post
WizardStepMixin.jsContract: validate/saveDraft/restoreFromDraftdeclare mixin
Step1Personal.jsName/email/phone fieldsValidationTextBox
Step2Employment.jsDept/role/salary/startDate fieldsSelect, DateTextBox
Step3Access.jsPermission checkboxesCheckBox array
Step4Review.jsRead-only summary — rendered from draftdomConstruct.empty + innerHTML
topics.jsWIZARD_COMPLETED, WIZARD_CANCELLED constantsplain object

Capstone 3 Challenge

Extend the wizard with these features:

  1. Add a Cancel button that shows a confirmation Dialog before discarding the draft. The dialog must be a one-shot instance (destroyed on hide).
  2. Add localStorage persistence to DraftStore so refreshing the page mid-wizard restores the user's progress.
  3. Add a step-entry animation: when StackContainer switches panes, apply a CSS class "fade-in" to the incoming ContentPane's domNode for 400ms, then remove it.
// 1. Cancel with confirm Dialog
require(["dijit/ConfirmDialog"], function(ConfirmDialog) {
  var dlg = new ConfirmDialog({
    title: "Discard changes?",
    content: "All unsaved wizard data will be lost.",
    buttonOk: "Discard"
  });
  dlg.on("execute", function() {
    draft.remove("draft");
    localStorage.removeItem("wizardDraft");
    topic.publish(topics.WIZARD_CANCELLED);
  });
  dlg.on("hide", function() { dlg.destroyRecursive(); });
  dlg.show();
});

// 2. localStorage in DraftStore.create()
var saved = localStorage.getItem("wizardDraft");
var initial = saved ? JSON.parse(saved) : { id:"draft" };
initial.id = "draft";
var store = new Memory({ idProperty:"id", data:[initial] });
var origPut = store.put.bind(store);
store.put = function(obj) {
  localStorage.setItem("wizardDraft", JSON.stringify(obj));
  return origPut(obj);
};
return store;

// 3. Fade animation on step change
_goNext: function() {
  // ... validate + saveDraft ...
  this._stack.selectChild(this._steps[this._step].pane);
  var pane = this._steps[this._step].pane;
  domClass.add(pane.domNode, "fade-in");
  setTimeout(function() { domClass.remove(pane.domNode, "fade-in"); }, 400);
}

All Three Capstones Complete

You've built three production-grade Dojo applications integrating every phase concept. Finish with the Appendix reference cards — cheat sheets, gotcha lists, and migration notes.

Appendix & Reference → Tutorial Hub