DEV: Refactor Wizard components (#24770)

This commit refactors the Wizard component code in preparation for moving it to the 'static' directory for Embroider route-splitting. It also includes a number of general improvements and simplifications.

Extracted from https://github.com/discourse/discourse/pull/23678

Co-authored-by: Godfrey Chan <godfreykfc@gmail.com>
This commit is contained in:
David Taylor 2023-12-07 16:33:38 +00:00 committed by GitHub
parent 0139481188
commit e4c373194d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 741 additions and 538 deletions

View File

@ -6,62 +6,133 @@ import {
visit,
} from "@ember/test-helpers";
import { test } from "qunit";
import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("Wizard", function (needs) {
needs.user();
test("Wizard starts", async function (assert) {
await visit("/wizard");
assert.ok(exists(".wizard-container"));
assert.notOk(
exists(".d-header-wrap"),
"header is not rendered on wizard pages"
);
assert.dom(".wizard-container").exists();
assert
.dom(".d-header-wrap")
.doesNotExist("header is not rendered on wizard pages");
assert.strictEqual(currentRouteName(), "wizard.step");
});
test("Going back and forth in steps", async function (assert) {
await visit("/wizard/steps/hello-world");
assert.ok(exists(".wizard-container__step"));
assert.ok(
exists(".wizard-container__step.hello-world"),
"it adds a class for the step id"
);
assert.ok(
!exists(".wizard-container__button.finish"),
"cannot finish on first step"
);
assert.ok(exists(".wizard-container__step-progress"));
assert.ok(exists(".wizard-container__step-title"));
assert.ok(exists(".wizard-container__step-description"));
assert.ok(
!exists(".invalid #full_name"),
"don't show it as invalid until the user does something"
);
assert.ok(!exists(".wizard-container__button.btn-back"));
assert.ok(!exists(".wizard-container__field .error"));
assert.dom(".wizard-container__step").exists();
assert
.dom(".wizard-container__step.hello-world")
.exists("it adds a class for the step id");
assert.dom(".wizard-container__step-title").exists();
assert.dom(".wizard-container__step-description").exists();
assert
.dom(".invalid #full_name")
.doesNotExist("don't show it as invalid until the user does something");
assert.dom(".wizard-container__field .error").doesNotExist();
// First step: only next button
assert.dom(".wizard-canvas").doesNotExist("First step: no confetti");
assert
.dom(".wizard-container__button.back")
.doesNotExist("First step: no back button");
assert
.dom(".wizard-container__button.next")
.exists("First step: next button");
assert
.dom(".wizard-container__button.jump-in")
.doesNotExist("First step: no jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("First step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.doesNotExist("First step: no finish button");
// invalid data
await click(".wizard-container__button.next");
assert.ok(exists(".invalid #full_name"));
assert.dom(".invalid #full_name").exists();
// server validation fail
await fillIn("input#full_name", "Server Fail");
await click(".wizard-container__button.next");
assert.ok(exists(".invalid #full_name"));
assert.ok(exists(".wizard-container__field .error"));
assert.dom(".invalid #full_name").exists();
assert.dom(".wizard-container__field .error").exists();
// server validation ok
await fillIn("input#full_name", "Evil Trout");
await click(".wizard-container__button.next");
assert.ok(!exists(".wizard-container__field .error"));
assert.ok(!exists(".wizard-container__step-description"));
assert.ok(
exists(".wizard-container__button.finish"),
"shows finish on an intermediate step"
);
assert
.dom(".wizard-container__step.hello-again")
.exists("step: hello-again");
assert.dom(".wizard-container__field .error").doesNotExist();
assert.dom(".wizard-container__step-description").doesNotExist();
// Pre-ready: back and next buttons
assert.dom(".wizard-canvas").doesNotExist("Pre-ready step: no confetti");
assert
.dom(".wizard-container__button.back")
.exists("Pre-ready step: back button");
assert
.dom(".wizard-container__button.next")
.exists("Pre-ready step: next button");
assert
.dom(".wizard-container__button.jump-in")
.doesNotExist("Pre-ready step: no jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("Pre-ready step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.doesNotExist("Pre-ready step: no finish button");
// ok to skip an optional field
await click(".wizard-container__button.next");
assert.dom(".wizard-container__step.ready").exists("step: ready");
// Ready: back, configure-more and jump-in buttons
assert.dom(".wizard-canvas").exists("Ready step: confetti");
assert
.dom(".wizard-container__button.back")
.exists("Ready step: back button");
assert
.dom(".wizard-container__button.next")
.doesNotExist("Ready step: no next button");
assert
.dom(".wizard-container__button.jump-in")
.exists("Ready step: jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.exists("Ready step: configure-more button");
assert
.dom(".wizard-container__button.finish")
.doesNotExist("Ready step: no finish button");
// continue on to optional steps
await click(".wizard-container__button.configure-more");
assert.dom(".wizard-container__step.optional").exists("step: optional");
// Post-ready: back, next and finish buttons
assert.dom(".wizard-canvas").doesNotExist("Post-ready step: no confetti");
assert
.dom(".wizard-container__button.back")
.exists("Post-ready step: back button");
assert
.dom(".wizard-container__button.next")
.exists("Post-ready step: next button");
assert
.dom(".wizard-container__button.jump-in")
.doesNotExist("Post-ready step: no jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("Post-ready step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.exists("Post-ready step: finish button");
// finish early, does not save/validate
await click(".wizard-container__button.finish");
assert.strictEqual(
currentURL(),
@ -69,51 +140,107 @@ acceptance("Wizard", function (needs) {
"it should transition to the homepage"
);
await visit("/wizard/steps/styling");
await visit("/wizard/steps/optional");
assert.dom(".wizard-container__step.optional").exists("step: optional");
// Post-ready: back, next and finish buttons
assert.dom(".wizard-canvas").doesNotExist("Post-ready step: no confetti");
assert
.dom(".wizard-container__button.back")
.exists("Post-ready step: back button");
assert
.dom(".wizard-container__button.next")
.exists("Post-ready step: next button");
assert
.dom(".wizard-container__button.jump-in")
.doesNotExist("Post-ready step: no jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("Post-ready step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.exists("Post-ready step: finish button");
await click(".wizard-container__button.primary.next");
assert.ok(
exists(".wizard-container__text-input#company_name"),
"went to the next step"
);
assert.ok(
exists(".wizard-container__preview"),
"renders the component field"
);
assert.ok(
exists(".wizard-container__button.jump-in"),
"last step shows a jump in button"
);
assert.ok(
exists(".wizard-container__button.btn-back"),
"shows the back button"
);
assert.ok(!exists(".wizard-container__step-title"));
assert.ok(
!exists(".wizard-container__button.next"),
"does not show next button"
);
assert.ok(
!exists(".wizard-container__button.finish"),
"cannot finish on last step"
);
assert.dom(".wizard-container__step.corporate").exists("step: corporate");
// Final step: back and jump-in buttons
assert.dom(".wizard-canvas").doesNotExist("Finish step: no confetti");
assert
.dom(".wizard-container__button.back")
.exists("Finish step: back button");
assert
.dom(".wizard-container__button.next")
.doesNotExist("Finish step: no next button");
assert
.dom(".wizard-container__button.jump-in")
.exists("Finish step: jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("Finish step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.doesNotExist("Finish step: no finish button");
assert
.dom(".wizard-container__text-input#company_name")
.exists("went to the next step");
assert
.dom(".wizard-container__preview")
.exists("renders the component field");
assert.dom(".wizard-container__step-title").doesNotExist();
await click(".wizard-container__button.back");
assert.dom(".wizard-container__step.optional").exists("step: optional");
// Post-ready: back, next and finish buttons
assert.dom(".wizard-canvas").doesNotExist("Post-ready step: no confetti");
assert
.dom(".wizard-container__button.back")
.exists("Post-ready step: back button");
assert
.dom(".wizard-container__button.next")
.exists("Post-ready step: next button");
assert
.dom(".wizard-container__button.jump-in")
.doesNotExist("Post-ready step: no jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("Post-ready step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.exists("Post-ready step: finish button");
assert.dom(".wizard-container__step-title").exists("shows the step title");
await click(".wizard-container__button.btn-back");
assert.ok(exists(".wizard-container__step-title"), "shows the step title");
assert.ok(
exists(".wizard-container__button.next"),
"shows the next button"
);
await click(".wizard-container__button.next");
assert.dom(".wizard-container__step.corporate").exists("step: optional");
// Final step: back and jump-in buttons
assert.dom(".wizard-canvas").doesNotExist("Finish step: no confetti");
assert
.dom(".wizard-container__button.back")
.exists("Finish step: back button");
assert
.dom(".wizard-container__button.next")
.doesNotExist("Finish step: no next button");
assert
.dom(".wizard-container__button.jump-in")
.exists("Finish step: jump-in button");
assert
.dom(".wizard-container__button.configure-more")
.doesNotExist("Finish step: no configure-more button");
assert
.dom(".wizard-container__button.finish")
.doesNotExist("Finish step: no finish button");
// server validation fail
await fillIn("input#company_name", "Server Fail");
await click(".wizard-container__button.jump-in");
assert.ok(
exists(".invalid #company_name"),
"highlights the field with error"
);
assert.ok(exists(".wizard-container__field .error"), "shows the error");
assert
.dom(".invalid #company_name")
.exists("highlights the field with error");
assert.dom(".wizard-container__field .error").exists("shows the error");
await fillIn("input#company_name", "Foo Bar");
await click(".wizard-container__button.jump-in");

View File

@ -1,5 +1,5 @@
{{component
this.componentName
this.component
class="wizard-container__dropdown"
value=this.field.value
content=this.field.choices

View File

@ -1,6 +1,8 @@
import Component from "@ember/component";
import { action, set } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import ColorPalettes from "select-kit/components/color-palettes";
import ComboBox from "select-kit/components/combo-box";
export default Component.extend({
init() {
@ -16,8 +18,8 @@ export default Component.extend({
},
@discourseComputed("field.id")
componentName(id) {
return id === "color_scheme" ? "color-palettes" : "combo-box";
component(id) {
return id === "color_scheme" ? ColorPalettes : ComboBox;
},
keyPress(e) {

View File

@ -0,0 +1,9 @@
import Generic from "./generic";
import Logo from "./logo";
import LogoSmall from "./logo-small";
export default {
generic: Generic,
logo: Logo,
"logo-small": LogoSmall,
};

View File

@ -1,8 +1,8 @@
import { action } from "@ember/object";
import { drawHeader, LOREM } from "wizard/lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base";
import { drawHeader, LOREM } from "../../../lib/preview";
import PreviewBaseComponent from "../styling-preview/-preview-base";
export default WizardPreviewBaseComponent.extend({
export default PreviewBaseComponent.extend({
width: 375,
height: 100,
image: null,

View File

@ -1,8 +1,8 @@
import { action } from "@ember/object";
import { drawHeader } from "wizard/lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base";
import { drawHeader } from "../../../lib/preview";
import PreviewBaseComponent from "../styling-preview/-preview-base";
export default WizardPreviewBaseComponent.extend({
export default PreviewBaseComponent.extend({
width: 400,
height: 100,
image: null,

View File

@ -5,10 +5,10 @@ import { dasherize } from "@ember/string";
import Uppy from "@uppy/core";
import DropTarget from "@uppy/drop-target";
import XHRUpload from "@uppy/xhr-upload";
import { getOwnerWithFallback } from "discourse-common/lib/get-owner";
import getUrl from "discourse-common/lib/get-url";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import imagePreviews from "./image-previews";
export default Component.extend({
classNames: ["wizard-container__image-upload"],
@ -17,11 +17,7 @@ export default Component.extend({
@discourseComputed("field.id")
previewComponent(id) {
const componentName = `image-preview-${dasherize(id)}`;
const exists = getOwnerWithFallback(this).lookup(
`component:${componentName}`
);
return exists ? componentName : "wizard-image-preview";
return imagePreviews[dasherize(id)] ?? imagePreviews.generic;
},
didInsertElement() {

View File

@ -0,0 +1,15 @@
import Checkbox from "./checkbox";
import Checkboxes from "./checkboxes";
import Dropdown from "./dropdown";
import Image from "./image";
import StylingPreview from "./styling-preview";
import Text from "./text";
export default {
checkbox: Checkbox,
checkboxes: Checkboxes,
"styling-preview": StylingPreview,
dropdown: Dropdown,
image: Image,
text: Text,
};

View File

@ -1,7 +1,7 @@
import { darkLightDiff, LOREM } from "wizard/lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base";
import { darkLightDiff, LOREM } from "../../../lib/preview";
import PreviewBaseComponent from "./-preview-base";
export default WizardPreviewBaseComponent.extend({
export default PreviewBaseComponent.extend({
width: 628,
height: 322,
logo: null,

View File

@ -6,7 +6,7 @@ import { htmlSafe } from "@ember/template";
import { Promise } from "rsvp";
import PreloadStore from "discourse/lib/preload-store";
import getUrl from "discourse-common/lib/get-url";
import { darkLightDiff, drawHeader } from "wizard/lib/preview";
import { darkLightDiff, drawHeader } from "../../../lib/preview";
export const LOREM = `
Lorem ipsum dolor sit amet,

View File

@ -8,7 +8,7 @@
</canvas>
</div>
<div class="wizard-container__preview homepage-preview">
<HomepagePreview @wizard={{this.wizard}} @step={{this.step}} />
<this.HomepagePreview @wizard={{this.wizard}} @step={{this.step}} />
</div>
</div>

View File

@ -1,8 +1,9 @@
import { action } from "@ember/object";
import { bind, observes } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { chooseDarker, darkLightDiff } from "wizard/lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base";
import { chooseDarker, darkLightDiff } from "../../../lib/preview";
import HomepagePreview from "./-homepage-preview";
import PreviewBaseComponent from "./-preview-base";
const LOREM = `
Lorem ipsum dolor sit amet, consectetur adipiscing.
@ -10,7 +11,7 @@ Nullam eget sem non elit tincidunt rhoncus. Fusce
velit nisl, porttitor sed nisl ac, consectetur interdum
metus. Fusce in consequat augue, vel facilisis felis.`;
export default WizardPreviewBaseComponent.extend({
export default PreviewBaseComponent.extend({
width: 628,
height: 322,
logo: null,
@ -19,6 +20,7 @@ export default WizardPreviewBaseComponent.extend({
draggingActive: false,
startX: 0,
scrollLeft: 0,
HomepagePreview,
init() {
this._super(...arguments);

View File

@ -1,11 +1,12 @@
import Component from "@ember/component";
import Component from "@glimmer/component";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import willDestroy from "@ember/render-modifiers/modifiers/will-destroy";
import { bind } from "discourse-common/utils/decorators";
const MAX_PARTICLES = 150;
const SIZE = 144;
let width, height;
const COLORS = [
"--tertiary",
"--quaternary",
@ -14,13 +15,12 @@ const COLORS = [
];
class Particle {
constructor() {
this.reset();
this.y = Math.random() * (height + SIZE) - SIZE;
constructor(width, height) {
this.reset(width, height);
}
reset() {
this.y = -SIZE;
reset(width, height) {
this.y = Math.random() * (height + SIZE) - SIZE;
this.origX = Math.random() * (width + SIZE);
this.speed = 0.5 + Math.random();
this.ang = Math.random() * 2 * Math.PI;
@ -31,11 +31,13 @@ class Particle {
this.flipped = Math.random() > 0.5 ? 1 : -1;
}
move() {
move(width, height) {
this.y += this.speed;
if (this.y > height + SIZE) {
this.reset();
this.reset(width, height);
// start at the top
this.y = -SIZE;
}
this.ang += this.speed / 30.0;
@ -47,66 +49,66 @@ class Particle {
}
}
export default Component.extend({
classNames: ["wizard-canvas"],
tagName: "canvas",
ctx: null,
ready: false,
particles: null,
export default class WizardCanvasComponent extends Component {
canvas = null;
particles = null;
didInsertElement() {
this._super(...arguments);
get ready() {
return this.canvas !== null;
}
const canvas = this.element;
this.ctx = canvas.getContext("2d");
get ctx() {
return this.canvas.getContext("2d");
}
@bind
setup(canvas) {
this.canvas = canvas;
this.resized();
let { width, height } = canvas;
this.particles = [];
for (let i = 0; i < MAX_PARTICLES; i++) {
this.particles.push(new Particle());
this.particles.push(new Particle(width, height));
}
this.ready = true;
this.paint();
this.paint(width, height);
window.addEventListener("resize", this.resized);
},
willDestroyElement() {
this._super(...arguments);
}
@bind
teardown() {
this.canvas = null;
window.removeEventListener("resize", this.resized);
},
}
@bind
resized() {
width = window.innerWidth;
height = window.innerHeight;
const canvas = this.element;
canvas.width = width;
canvas.height = height;
},
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
@bind
paint() {
if (this.isDestroying || this.isDestroyed || !this.ready) {
if (!this.ready) {
return;
}
const { ctx } = this;
let { ctx } = this;
let { width, height } = this.canvas;
ctx.clearRect(0, 0, width, height);
this.particles.forEach((particle) => {
particle.move();
this.drawParticle(particle);
});
for (let particle of this.particles) {
particle.move(width, height);
this.drawParticle(ctx, particle);
}
window.requestAnimationFrame(() => this.paint());
},
drawParticle(p) {
const c = this.ctx;
window.requestAnimationFrame(this.paint);
}
drawParticle(c, p) {
c.save();
c.translate(p.x - SIZE, p.y - SIZE);
c.scale(p.scale * p.flipped, p.scale);
@ -174,5 +176,13 @@ export default Component.extend({
c.fill();
c.stroke();
c.restore();
},
});
}
<template>
<canvas
class="wizard-canvas"
{{didInsert this.setup}}
{{willDestroy this.teardown}}
/>
</template>
}

View File

@ -0,0 +1,83 @@
import Component from "@glimmer/component";
import { assert } from "@ember/debug";
import { dasherize } from "@ember/string";
import { htmlSafe } from "@ember/template";
import fields from "./fields";
export default class WizardFieldComponent extends Component {
get field() {
return this.args.field;
}
get classes() {
let classes = ["wizard-container__field"];
let { type, id, invalid, disabled } = this.field;
classes.push(`${dasherize(type)}-field`);
classes.push(`${dasherize(type)}-${dasherize(id)}`);
if (invalid) {
classes.push("invalid");
}
if (disabled) {
classes.push("disabled");
}
return classes.join(" ");
}
get fieldClass() {
return `field-${dasherize(this.field.id)} wizard-focusable`;
}
get component() {
let { type } = this.field;
assert(`"${type}" is not a valid wizard field type`, type in fields);
return fields[type];
}
<template>
<div class={{this.classes}}>
{{#if @field.label}}
<label for={{@field.id}}>
<span class="wizard-container__label">
{{@field.label}}
</span>
{{#if @field.required}}
<span class="wizard-container__label required">*</span>
{{/if}}
{{#if @field.description}}
<div class="wizard-container__description">
{{htmlSafe @field.description}}
</div>
{{/if}}
</label>
{{/if}}
<div class="wizard-container__input">
<this.component
@wizard={{@wizard}}
@step={{@step}}
@field={{@field}}
@fieldClass={{this.fieldClass}}
/>
</div>
{{#if @field.errorDescription}}
<div class="wizard-container__description error">
{{htmlSafe this.field.errorDescription}}
</div>
{{/if}}
{{#if @field.extraDescription}}
<div class="wizard-container__description extra">
{{htmlSafe this.field.extraDescription}}
</div>
{{/if}}
</div>
</template>
}

View File

@ -1,39 +0,0 @@
{{#if this.field.label}}
<label for={{this.field.id}}>
<span class="wizard-container__label">
{{this.field.label}}
</span>
{{#if this.field.required}}
<span class="wizard-container__label required">*</span>
{{/if}}
{{#if this.field.description}}
<div class="wizard-container__description">{{html-safe
this.field.description
}}</div>
{{/if}}
</label>
{{/if}}
<div class="wizard-container__input">
{{component
this.inputComponentName
field=this.field
step=this.step
fieldClass=this.fieldClass
wizard=this.wizard
}}
</div>
{{#if this.field.errorDescription}}
<div class="wizard-container__description error">{{html-safe
this.field.errorDescription
}}</div>
{{/if}}
{{#if this.field.extraDescription}}
<div class="wizard-container__description extra">{{html-safe
this.field.extraDescription
}}</div>
{{/if}}

View File

@ -1,24 +0,0 @@
import Component from "@ember/component";
import { dasherize } from "@ember/string";
import discourseComputed from "discourse-common/utils/decorators";
export default Component.extend({
classNameBindings: [
":wizard-container__field",
"typeClasses",
"field.invalid",
"field.disabled",
],
@discourseComputed("field.type", "field.id")
typeClasses: (type, id) =>
`${dasherize(type)}-field ${dasherize(type)}-${dasherize(id)}`,
@discourseComputed("field.id")
fieldClass: (id) => `field-${dasherize(id)} wizard-focusable`,
@discourseComputed("field.type", "field.id")
inputComponentName(type, id) {
return type === "component" ? dasherize(id) : `wizard-field-${type}`;
},
});

View File

@ -1,5 +0,0 @@
import Component from "@ember/component";
export default Component.extend({
classNameBindings: [":wizard-container__step-form"],
});

View File

@ -0,0 +1,301 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { schedule } from "@ember/runloop";
import { htmlSafe } from "@ember/template";
import emoji from "discourse/helpers/emoji";
import I18n from "discourse-i18n";
import WizardField from "./wizard-field";
const i18n = (...args) => I18n.t(...args);
export default class WizardStepComponent extends Component {
@tracked saving = false;
get wizard() {
return this.args.wizard;
}
get step() {
return this.args.step;
}
get id() {
return this.step.id;
}
/**
* Step Back Button? Primary Action Secondary Action
* ------------------------------------------------------------------
* First No Next N/A
* ------------------------------------------------------------------
* ... Yes Next N/A
* ------------------------------------------------------------------
* Ready Yes Jump In Configure More
* ------------------------------------------------------------------
* ... Yes Next Exit Setup
* ------------------------------------------------------------------
* Last Yes Jump In N/A
* ------------------------------------------------------------------
*
* Back Button: without saving, go back to the last page
* Next Button: save, and if successful, go to the next page
* Configure More: re-skinned next button
* Exit Setup: without saving, go to the home page ("finish")
* Jump In: on the "ready" page, it exits the setup ("finish"), on the
* last page, it saves, and if successful, go to the home page
*/
get isFinalStep() {
return this.step.displayIndex === this.wizard.steps.length;
}
get showBackButton() {
return this.step.index > 0;
}
get showFinishButton() {
const ready = this.wizard.findStep("ready");
const isReady = ready && this.step.index > ready.index;
return isReady && !this.isFinalStep;
}
get showConfigureMore() {
return this.id === "ready";
}
get showJumpInButton() {
return this.id === "ready" || this.isFinalStep;
}
get includeSidebar() {
return !!this.step.fields.find((f) => f.showInSidebar);
}
@action
stepChanged() {
this.saving = false;
this.autoFocus();
}
@action
onKeyUp(event) {
if (event.key === "Enter") {
if (this.showJumpInButton) {
this.jumpIn();
} else {
this.nextStep();
}
}
}
@action
autoFocus() {
schedule("afterRender", () => {
const firstInvalidElement = document.querySelector(
".wizard-container__input.invalid:nth-of-type(1) .wizard-focusable"
);
if (firstInvalidElement) {
return firstInvalidElement.focus();
}
document.querySelector(".wizard-focusable:nth-of-type(1)")?.focus();
});
}
async advance() {
try {
this.saving = true;
const response = await this.step.save();
this.args.goNext(response);
} finally {
this.saving = false;
}
}
@action
finish(event) {
event?.preventDefault();
if (this.saving) {
return;
}
this.args.goHome();
}
@action
jumpIn(event) {
event?.preventDefault();
if (this.saving) {
return;
}
if (this.id === "ready") {
this.finish();
} else {
this.nextStep();
}
}
@action
backStep(event) {
event?.preventDefault();
if (this.saving) {
return;
}
this.args.goBack();
}
@action
nextStep(event) {
event?.preventDefault();
if (this.saving) {
return;
}
if (this.step.validate()) {
this.advance();
} else {
this.autoFocus();
}
}
<template>
<div
class="wizard-container__step {{@step.id}}"
{{didInsert this.autoFocus}}
{{didUpdate this.stepChanged @step.id}}
>
<div class="wizard-container__step-counter">
<span class="wizard-container__step-text">
{{i18n "wizard.step-text"}}
</span>
<span class="wizard-container__step-count">
{{i18n
"wizard.step"
current=@step.displayIndex
total=@wizard.totalSteps
}}
</span>
</div>
<div class="wizard-container">
<div class="wizard-container__step-contents">
<div class="wizard-container__step-header">
{{#if @step.emoji}}
<div class="wizard-container__step-header--emoji">
{{emoji @step.emoji}}
</div>
{{/if}}
{{#if @step.title}}
<h1 class="wizard-container__step-title">{{@step.title}}</h1>
{{#if @step.description}}
<p class="wizard-container__step-description">
{{htmlSafe @step.description}}
</p>
{{/if}}
{{/if}}
</div>
<div class="wizard-container__step-container">
{{#if @step.fields}}
<div class="wizard-container__step-form">
{{#if this.includeSidebar}}
<div class="wizard-container__sidebar">
{{#each @step.fields as |field|}}
{{#if field.showInSidebar}}
<WizardField
@field={{field}}
@step={{@step}}
@wizard={{@wizard}}
/>
{{/if}}
{{/each}}
</div>
{{/if}}
<div class="wizard-container__fields">
{{#each @step.fields as |field|}}
{{#unless field.showInSidebar}}
<WizardField
@field={{field}}
@step={{@step}}
@wizard={{@wizard}}
/>
{{/unless}}
{{/each}}
</div>
</div>
{{/if}}
</div>
</div>
<div class="wizard-container__step-footer">
<div class="wizard-container__buttons-left">
{{#if this.showBackButton}}
<button
{{on "click" this.backStep}}
disabled={{this.saving}}
type="button"
class="wizard-container__button back"
>
{{i18n "wizard.back"}}
</button>
{{/if}}
</div>
<div class="wizard-container__buttons-right">
{{#if this.showFinishButton}}
<button
{{on "click" this.finish}}
disabled={{this.saving}}
type="button"
class="wizard-container__button finish"
>
{{i18n "wizard.finish"}}
</button>
{{else if this.showConfigureMore}}
<button
{{on "click" this.nextStep}}
disabled={{this.saving}}
type="button"
class="wizard-container__button configure-more"
>
{{i18n "wizard.configure_more"}}
</button>
{{/if}}
{{#if this.showJumpInButton}}
<button
{{on "click" this.jumpIn}}
disabled={{this.saving}}
type="button"
class="wizard-container__button primary jump-in"
>
{{i18n "wizard.jump_in"}}
</button>
{{else}}
<button
{{on "click" this.nextStep}}
disabled={{this.saving}}
type="button"
class="wizard-container__button primary next"
>
{{i18n "wizard.next"}}
</button>
{{/if}}
</div>
</div>
</div>
</div>
</template>
}

View File

@ -1,124 +0,0 @@
<div class="wizard-container__step-counter">
<span class="wizard-container__step-text">{{bound-i18n
"wizard.step-text"
}}</span>
<span class="wizard-container__step-count">{{bound-i18n
"wizard.step"
current=this.step.displayIndex
total=this.wizard.totalSteps
}}</span>
</div>
<div class="wizard-container">
<div class="wizard-container__step-contents">
<div class="wizard-container__step-header">
{{#if this.step.emoji}}
<div class="wizard-container__step-header--emoji">
{{emoji this.step.emoji}}
</div>
{{/if}}
{{#if this.step.title}}
<h1 class="wizard-container__step-title">{{this.step.title}}</h1>
{{#if this.step.description}}
<p class="wizard-container__step-description">{{html-safe
this.step.description
}}</p>
{{/if}}
{{/if}}
</div>
<div class="wizard-container__step-container">
{{#if this.step.fields}}
<WizardStepForm @step={{this.step}}>
{{#if this.includeSidebar}}
<div class="wizard-container__sidebar">
{{#each this.step.fields as |field|}}
{{#if field.showInSidebar}}
<WizardField
@field={{field}}
@step={{this.step}}
@wizard={{this.wizard}}
/>
{{/if}}
{{/each}}
</div>
{{/if}}
<div class="wizard-container__fields">
{{#each this.step.fields as |field|}}
{{#unless field.showInSidebar}}
<WizardField
@field={{field}}
@step={{this.step}}
@wizard={{this.wizard}}
/>
{{/unless}}
{{/each}}
</div>
</WizardStepForm>
{{/if}}
</div>
</div>
<div class="wizard-container__step-footer">
<div class="wizard-container__buttons">
{{#if this.showBackButton}}
<button
{{on "click" this.backStep}}
disabled={{this.saving}}
type="button"
class="wizard-container__button btn-back"
>
{{i18n "wizard.back"}}
</button>
{{/if}}
</div>
<div class="wizard-container__step-progress">
{{#if this.showFinishButton}}
<button
{{on "click" this.exitEarly}}
disabled={{this.saving}}
type="button"
class="wizard-container__button jump-in"
>
{{i18n "wizard.jump_in"}}
</button>
{{/if}}
{{#if this.showConfigureMore}}
<button
{{on "click" this.nextStep}}
disabled={{this.saving}}
type="button"
class="wizard-container__button primary {{this.nextButtonClass}}"
>
{{i18n this.nextButtonLabel}}
</button>
{{/if}}
{{#if this.showJumpInButton}}
<button
{{on "click" this.quit}}
disabled={{this.saving}}
type="button"
class="wizard-container__button {{this.jumpInButtonClass}}"
>
{{i18n this.jumpInButtonLabel}}
</button>
{{/if}}
{{#if this.showNextButton}}
<button
{{on "click" this.nextStep}}
disabled={{this.saving}}
type="button"
class="wizard-container__button primary {{this.nextButtonClass}}"
>
{{i18n this.nextButtonLabel}}
</button>
{{/if}}
</div>
</div>
</div>

View File

@ -1,162 +0,0 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import $ from "jquery";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
export default Component.extend({
router: service(),
classNameBindings: [":wizard-container__step", "stepClass"],
saving: null,
didInsertElement() {
this._super(...arguments);
this.autoFocus();
},
@discourseComputed("step.index")
showBackButton(index) {
return index > 0;
},
@discourseComputed("step.displayIndex", "wizard.totalSteps")
showNextButton(current, total) {
if (this.showConfigureMore === true) {
return false;
}
return current < total;
},
@discourseComputed("step.id")
nextButtonLabel(step) {
return `wizard.${step === "ready" ? "configure_more" : "next"}`;
},
@discourseComputed("step.id")
nextButtonClass(step) {
return step === "ready" ? "configure-more" : "next";
},
@discourseComputed("step.id")
showConfigureMore(step) {
return step === "ready";
},
@discourseComputed("step.id")
showJumpInButton(step) {
return ["ready", "styling", "branding"].includes(step);
},
@discourseComputed("step.id")
jumpInButtonLabel(step) {
return `wizard.${step === "ready" ? "jump_in" : "finish"}`;
},
@discourseComputed("step.id")
jumpInButtonClass(step) {
return step === "ready" ? "jump-in" : "finish";
},
@discourseComputed("step.id")
showFinishButton(step) {
return step === "corporate";
},
@discourseComputed("step.id")
stepClass(step) {
return step;
},
@observes("step")
_stepChanged() {
this.set("saving", false);
this.autoFocus();
},
keyPress(event) {
if (event.key === "Enter") {
if (this.showJumpInButton) {
this.send("quit");
} else {
this.send("nextStep");
}
}
},
@discourseComputed("step.fields")
includeSidebar(fields) {
return !!fields.findBy("showInSidebar");
},
autoFocus() {
schedule("afterRender", () => {
const $invalid = $(
".wizard-container__input.invalid:nth-of-type(1) .wizard-focusable"
);
if ($invalid.length) {
return $invalid.focus();
}
$(".wizard-focusable:nth-of-type(1)").focus();
});
},
advance() {
this.set("saving", true);
this.step
.save()
.then((response) => this.goNext(response))
.finally(() => this.set("saving", false));
},
@action
quit(event) {
event?.preventDefault();
this.router.transitionTo("discovery.latest");
},
@action
exitEarly(event) {
event?.preventDefault();
const step = this.step;
if (step.validate()) {
this.set("saving", true);
step
.save()
.then((response) => this.goNext(response))
.finally(() => this.set("saving", false));
} else {
this.autoFocus();
}
},
@action
backStep(event) {
event?.preventDefault();
if (this.saving) {
return;
}
this.goBack();
},
@action
nextStep(event) {
event?.preventDefault();
if (this.saving) {
return;
}
if (this.step.validate()) {
this.advance();
} else {
this.autoFocus();
}
},
});

View File

@ -20,6 +20,7 @@ export default RouteTemplate(
@wizard={{@model.wizard}}
@goNext={{this.goNext}}
@goBack={{this.goBack}}
@goHome={{this.goHome}}
/>
</template>
@ -48,5 +49,10 @@ export default RouteTemplate(
goBack() {
this.router.transitionTo("wizard.step", this.step.previous);
}
@action
goHome() {
this.router.transitionTo("discovery.latest");
}
}
);

View File

@ -20,24 +20,47 @@ export default function (helpers) {
description: "Your name",
},
],
next: "styling",
next: "hello-again",
},
{
id: "styling",
title: "Second step",
id: "hello-again",
title: "hello again",
index: 1,
fields: [{ id: "some_title", type: "text" }],
fields: [
{
id: "nick_name",
type: "text",
required: false,
description: "Your nick name",
},
],
previous: "hello-world",
next: "ready",
},
{
id: "ready",
title: "your site is ready",
index: 2,
fields: [],
previous: "hello-again",
next: "optional",
},
{
id: "optional",
title: "Optional step",
index: 3,
fields: [{ id: "some_title", type: "text" }],
previous: "ready",
next: "corporate",
},
{
id: "corporate",
index: 2,
index: 4,
fields: [
{ id: "company_name", type: "text", required: true },
{ id: "styling_preview", type: "component" },
{ id: "styling_preview", type: "styling-preview" },
],
previous: "styling",
previous: "optional",
},
],
},
@ -47,11 +70,11 @@ export default function (helpers) {
this.put("/wizard/steps/:id", (request) => {
const body = parsePostData(request.requestBody);
if (body.fields.full_name === "Server Fail") {
if (body.fields?.full_name === "Server Fail") {
return response(422, {
errors: [{ field: "full_name", description: "Invalid name" }],
});
} else if (body.fields.company_name === "Server Fail") {
} else if (body.fields?.company_name === "Server Fail") {
return response(422, {
errors: [
{ field: "company_name", description: "Invalid company name" },

View File

@ -290,17 +290,21 @@ body.wizard {
}
}
&__step.ready {
.wizard-container__buttons {
flex-direction: row-reverse;
}
}
&__step.branding .wizard-container__description {
font-size: var(--font-0);
}
&__step-progress {
&__buttons-left {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: center;
@include breakpoint("mobile-extra-large") {
order: 2;
}
}
&__buttons-right {
display: flex;
align-items: center;
font-weight: bold;
@ -309,15 +313,6 @@ body.wizard {
margin-right: 0;
flex-direction: column;
}
.wizard-container__link {
color: var(--primary-400);
margin: 0 1em;
&.inactive {
// disabling instead of removing, to hold space
pointer-events: none;
opacity: 0;
}
}
}
&__step-text {
@ -381,8 +376,13 @@ body.wizard {
}
&__button.primary {
margin-left: 1em;
background-color: var(--tertiary);
color: var(--secondary);
@include breakpoint("mobile-extra-large") {
order: 1;
margin-left: 0;
}
}
&__button.primary:hover,
&__button.primary:focus {
@ -412,13 +412,6 @@ body.wizard {
}
&__button.jump-in {
background-color: var(--tertiary);
color: var(--secondary);
margin-left: 1em;
@include breakpoint("mobile-extra-large") {
order: 1;
margin-left: 0;
}
&:hover {
background-color: var(--primary-300);
}
@ -506,16 +499,6 @@ body.wizard {
}
}
&__buttons {
display: flex;
flex-wrap: wrap;
gap: 1em;
align-items: center;
@include breakpoint("mobile-extra-large") {
order: 2;
}
}
&__label {
font-weight: bold;
font-size: var(--font-up-1);

View File

@ -194,7 +194,7 @@ class Wizard
style.add_choice("latest")
CategoryPageStyle.values.each { |page| style.add_choice(page[:value]) }
step.add_field(id: "styling_preview", type: "component")
step.add_field(id: "styling_preview", type: "styling-preview")
step.on_update do |updater|
updater.update_setting(:base_font, updater.fields[:body_font])