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

View File

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

View File

@ -1,6 +1,8 @@
import Component from "@ember/component"; import Component from "@ember/component";
import { action, set } from "@ember/object"; import { action, set } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators"; 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({ export default Component.extend({
init() { init() {
@ -16,8 +18,8 @@ export default Component.extend({
}, },
@discourseComputed("field.id") @discourseComputed("field.id")
componentName(id) { component(id) {
return id === "color_scheme" ? "color-palettes" : "combo-box"; return id === "color_scheme" ? ColorPalettes : ComboBox;
}, },
keyPress(e) { 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 { action } from "@ember/object";
import { drawHeader, LOREM } from "wizard/lib/preview"; import { drawHeader, LOREM } from "../../../lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base"; import PreviewBaseComponent from "../styling-preview/-preview-base";
export default WizardPreviewBaseComponent.extend({ export default PreviewBaseComponent.extend({
width: 375, width: 375,
height: 100, height: 100,
image: null, image: null,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import { action } from "@ember/object"; import { action } from "@ember/object";
import { bind, observes } from "discourse-common/utils/decorators"; import { bind, observes } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n"; import I18n from "discourse-i18n";
import { chooseDarker, darkLightDiff } from "wizard/lib/preview"; import { chooseDarker, darkLightDiff } from "../../../lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base"; import HomepagePreview from "./-homepage-preview";
import PreviewBaseComponent from "./-preview-base";
const LOREM = ` const LOREM = `
Lorem ipsum dolor sit amet, consectetur adipiscing. 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 velit nisl, porttitor sed nisl ac, consectetur interdum
metus. Fusce in consequat augue, vel facilisis felis.`; metus. Fusce in consequat augue, vel facilisis felis.`;
export default WizardPreviewBaseComponent.extend({ export default PreviewBaseComponent.extend({
width: 628, width: 628,
height: 322, height: 322,
logo: null, logo: null,
@ -19,6 +20,7 @@ export default WizardPreviewBaseComponent.extend({
draggingActive: false, draggingActive: false,
startX: 0, startX: 0,
scrollLeft: 0, scrollLeft: 0,
HomepagePreview,
init() { init() {
this._super(...arguments); 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"; import { bind } from "discourse-common/utils/decorators";
const MAX_PARTICLES = 150; const MAX_PARTICLES = 150;
const SIZE = 144; const SIZE = 144;
let width, height;
const COLORS = [ const COLORS = [
"--tertiary", "--tertiary",
"--quaternary", "--quaternary",
@ -14,13 +15,12 @@ const COLORS = [
]; ];
class Particle { class Particle {
constructor() { constructor(width, height) {
this.reset(); this.reset(width, height);
this.y = Math.random() * (height + SIZE) - SIZE;
} }
reset() { reset(width, height) {
this.y = -SIZE; this.y = Math.random() * (height + SIZE) - SIZE;
this.origX = Math.random() * (width + SIZE); this.origX = Math.random() * (width + SIZE);
this.speed = 0.5 + Math.random(); this.speed = 0.5 + Math.random();
this.ang = Math.random() * 2 * Math.PI; this.ang = Math.random() * 2 * Math.PI;
@ -31,11 +31,13 @@ class Particle {
this.flipped = Math.random() > 0.5 ? 1 : -1; this.flipped = Math.random() > 0.5 ? 1 : -1;
} }
move() { move(width, height) {
this.y += this.speed; this.y += this.speed;
if (this.y > height + SIZE) { if (this.y > height + SIZE) {
this.reset(); this.reset(width, height);
// start at the top
this.y = -SIZE;
} }
this.ang += this.speed / 30.0; this.ang += this.speed / 30.0;
@ -47,66 +49,66 @@ class Particle {
} }
} }
export default Component.extend({ export default class WizardCanvasComponent extends Component {
classNames: ["wizard-canvas"], canvas = null;
tagName: "canvas", particles = null;
ctx: null,
ready: false,
particles: null,
didInsertElement() { get ready() {
this._super(...arguments); return this.canvas !== null;
}
const canvas = this.element; get ctx() {
this.ctx = canvas.getContext("2d"); return this.canvas.getContext("2d");
}
@bind
setup(canvas) {
this.canvas = canvas;
this.resized(); this.resized();
let { width, height } = canvas;
this.particles = []; this.particles = [];
for (let i = 0; i < MAX_PARTICLES; i++) { 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(width, height);
this.paint();
window.addEventListener("resize", this.resized); window.addEventListener("resize", this.resized);
}, }
willDestroyElement() {
this._super(...arguments);
@bind
teardown() {
this.canvas = null;
window.removeEventListener("resize", this.resized); window.removeEventListener("resize", this.resized);
}, }
@bind @bind
resized() { resized() {
width = window.innerWidth; this.canvas.width = window.innerWidth;
height = window.innerHeight; this.canvas.height = window.innerHeight;
}
const canvas = this.element;
canvas.width = width;
canvas.height = height;
},
@bind
paint() { paint() {
if (this.isDestroying || this.isDestroyed || !this.ready) { if (!this.ready) {
return; return;
} }
const { ctx } = this; let { ctx } = this;
let { width, height } = this.canvas;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
this.particles.forEach((particle) => { for (let particle of this.particles) {
particle.move(); particle.move(width, height);
this.drawParticle(particle); this.drawParticle(ctx, particle);
}); }
window.requestAnimationFrame(() => this.paint()); window.requestAnimationFrame(this.paint);
}, }
drawParticle(p) {
const c = this.ctx;
drawParticle(c, p) {
c.save(); c.save();
c.translate(p.x - SIZE, p.y - SIZE); c.translate(p.x - SIZE, p.y - SIZE);
c.scale(p.scale * p.flipped, p.scale); c.scale(p.scale * p.flipped, p.scale);
@ -174,5 +176,13 @@ export default Component.extend({
c.fill(); c.fill();
c.stroke(); c.stroke();
c.restore(); 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}} @wizard={{@model.wizard}}
@goNext={{this.goNext}} @goNext={{this.goNext}}
@goBack={{this.goBack}} @goBack={{this.goBack}}
@goHome={{this.goHome}}
/> />
</template> </template>
@ -48,5 +49,10 @@ export default RouteTemplate(
goBack() { goBack() {
this.router.transitionTo("wizard.step", this.step.previous); 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", description: "Your name",
}, },
], ],
next: "styling", next: "hello-again",
}, },
{ {
id: "styling", id: "hello-again",
title: "Second step", title: "hello again",
index: 1, index: 1,
fields: [{ id: "some_title", type: "text" }], fields: [
{
id: "nick_name",
type: "text",
required: false,
description: "Your nick name",
},
],
previous: "hello-world", 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", next: "corporate",
}, },
{ {
id: "corporate", id: "corporate",
index: 2, index: 4,
fields: [ fields: [
{ id: "company_name", type: "text", required: true }, { 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) => { this.put("/wizard/steps/:id", (request) => {
const body = parsePostData(request.requestBody); const body = parsePostData(request.requestBody);
if (body.fields.full_name === "Server Fail") { if (body.fields?.full_name === "Server Fail") {
return response(422, { return response(422, {
errors: [{ field: "full_name", description: "Invalid name" }], 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, { return response(422, {
errors: [ errors: [
{ field: "company_name", description: "Invalid company name" }, { 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 { &__step.branding .wizard-container__description {
font-size: var(--font-0); 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; display: flex;
align-items: center; align-items: center;
font-weight: bold; font-weight: bold;
@ -309,15 +313,6 @@ body.wizard {
margin-right: 0; margin-right: 0;
flex-direction: column; 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 { &__step-text {
@ -381,8 +376,13 @@ body.wizard {
} }
&__button.primary { &__button.primary {
margin-left: 1em;
background-color: var(--tertiary); background-color: var(--tertiary);
color: var(--secondary); color: var(--secondary);
@include breakpoint("mobile-extra-large") {
order: 1;
margin-left: 0;
}
} }
&__button.primary:hover, &__button.primary:hover,
&__button.primary:focus { &__button.primary:focus {
@ -412,13 +412,6 @@ body.wizard {
} }
&__button.jump-in { &__button.jump-in {
background-color: var(--tertiary);
color: var(--secondary);
margin-left: 1em;
@include breakpoint("mobile-extra-large") {
order: 1;
margin-left: 0;
}
&:hover { &:hover {
background-color: var(--primary-300); 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 { &__label {
font-weight: bold; font-weight: bold;
font-size: var(--font-up-1); font-size: var(--font-up-1);

View File

@ -194,7 +194,7 @@ class Wizard
style.add_choice("latest") style.add_choice("latest")
CategoryPageStyle.values.each { |page| style.add_choice(page[:value]) } 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| step.on_update do |updater|
updater.update_setting(:base_font, updater.fields[:body_font]) updater.update_setting(:base_font, updater.fields[:body_font])