DEV: Modernize Wizard model implementation (#23640)

+ native classes
+ tracked properties
- Ember.Object
- Ember.Evented
- observers
- mixins
- computed/discourseComputed

Also removes unused wizard infrastructure for warnings. It appears
that once upon on time, either the server can generate warnings,
or some client code can generate them, which requires an extra 
confirmation from the user before they can continue to the next step.

This code is not tested and appears unused and defunct. Nothing
generates such warning and the server does not serialize them.

Extracted from https://github.com/discourse/discourse/pull/23678
This commit is contained in:
Godfrey Chan 2023-11-23 08:35:51 -08:00 committed by GitHub
parent 7c9cf666da
commit 2228f75645
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 357 additions and 254 deletions

View File

@ -1,44 +1,38 @@
import { getOwner } from "@ember/application";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import { Field } from "wizard/models/wizard";
module("Unit | Model | Wizard | wizard-field", function (hooks) {
setupTest(hooks);
test("basic state", function (assert) {
const store = getOwner(this).lookup("service:store");
const field = store.createRecord("wizard-field", { type: "text" });
const field = new Field({ type: "text" });
assert.ok(field.unchecked);
assert.ok(!field.valid);
assert.ok(!field.invalid);
});
test("text - required - validation", function (assert) {
const store = getOwner(this).lookup("service:store");
const field = store.createRecord("wizard-field", {
type: "text",
required: true,
});
const field = new Field({ type: "text", required: true });
assert.ok(field.unchecked);
field.check();
field.validate();
assert.ok(!field.unchecked);
assert.ok(!field.valid);
assert.ok(field.invalid);
field.set("value", "a value");
field.check();
field.value = "a value";
field.validate();
assert.ok(!field.unchecked);
assert.ok(field.valid);
assert.ok(!field.invalid);
});
test("text - optional - validation", function (assert) {
const store = getOwner(this).lookup("service:store");
const field = store.createRecord("wizard-field", { type: "text" });
const field = new Field({ type: "text" });
assert.ok(field.unchecked);
field.check();
field.validate();
assert.ok(field.valid);
});
});

View File

@ -15,7 +15,7 @@ export default WizardPreviewBaseComponent.extend({
images() {
return {
logo: this.wizard.getLogoUrl(),
logo: this.wizard.logoUrl,
avatar: "/images/wizard/trout.png",
};
},
@ -23,25 +23,25 @@ export default WizardPreviewBaseComponent.extend({
paint({ ctx, colors, font, width, height }) {
this.drawFullHeader(colors, font, this.logo);
if (this.get("step.fieldsById.homepage_style.value") === "latest") {
const homepageStyle = this.getHomepageStyle();
if (homepageStyle === "latest") {
this.drawPills(colors, font, height * 0.15);
this.renderLatest(ctx, colors, font, width, height);
} else if (
["categories_only", "categories_with_featured_topics"].includes(
this.get("step.fieldsById.homepage_style.value")
homepageStyle
)
) {
this.drawPills(colors, font, height * 0.15, { categories: true });
this.renderCategories(ctx, colors, font, width, height);
} else if (
["categories_boxes", "categories_boxes_with_topics"].includes(
this.get("step.fieldsById.homepage_style.value")
homepageStyle
)
) {
this.drawPills(colors, font, height * 0.15, { categories: true });
const topics =
this.get("step.fieldsById.homepage_style.value") ===
"categories_boxes_with_topics";
const topics = homepageStyle === "categories_boxes_with_topics";
this.renderCategoriesBoxes(ctx, colors, font, width, height, { topics });
} else {
this.drawPills(colors, font, height * 0.15, { categories: true });
@ -146,9 +146,10 @@ export default WizardPreviewBaseComponent.extend({
ctx.font = `${bodyFontSize * 0.9}em '${font}'`;
ctx.fillStyle = textColor;
ctx.fillText("Category", cols[0], headingY);
if (
this.get("step.fieldsById.homepage_style.value") === "categories_only"
) {
const homepageStyle = this.getHomepageStyle();
if (homepageStyle === "categories_only") {
ctx.fillText("Topics", cols[4], headingY);
} else {
ctx.fillText("Topics", cols[1], headingY);
@ -183,10 +184,7 @@ export default WizardPreviewBaseComponent.extend({
ctx.lineTo(margin, y + categoryHeight);
ctx.stroke();
if (
this.get("step.fieldsById.homepage_style.value") ===
"categories_with_featured_topics"
) {
if (homepageStyle === "categories_with_featured_topics") {
ctx.font = `${bodyFontSize}em '${font}'`;
ctx.fillText(
Math.floor(Math.random() * 90) + 10,
@ -204,10 +202,7 @@ export default WizardPreviewBaseComponent.extend({
});
// Featured Topics
if (
this.get("step.fieldsById.homepage_style.value") ===
"categories_with_featured_topics"
) {
if (homepageStyle === "categories_with_featured_topics") {
const topicHeight = height / 15;
y = headingY + bodyFontSize * 22;
@ -249,10 +244,7 @@ export default WizardPreviewBaseComponent.extend({
ctx.fillStyle = textColor;
ctx.fillText("Category", cols[0], headingY);
ctx.fillText("Topics", cols[1], headingY);
if (
this.get("step.fieldsById.homepage_style.value") ===
"categories_and_latest_topics"
) {
if (this.getHomepageStyle() === "categories_and_latest_topics") {
ctx.fillText("Latest", cols[2], headingY);
} else {
ctx.fillText("Top", cols[2], headingY);
@ -346,6 +338,10 @@ export default WizardPreviewBaseComponent.extend({
});
},
getHomepageStyle() {
return this.step.valueFor("homepage_style");
},
getTitles() {
return LOREM.split(".")
.slice(0, 8)

View File

@ -1,4 +1,4 @@
import { observes } from "discourse-common/utils/decorators";
import { action } from "@ember/object";
import { drawHeader, LOREM } from "wizard/lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base";
@ -7,13 +7,23 @@ export default WizardPreviewBaseComponent.extend({
height: 100,
image: null,
@observes("field.value")
didInsertElement() {
this._super(...arguments);
this.field.addListener(this.imageChanged);
},
willDestroyElement() {
this._super(...arguments);
this.field.removeListener(this.imageChanged);
},
@action
imageChanged() {
this.reload();
},
images() {
return { image: this.get("field.value") };
return { image: this.field.value };
},
paint(options) {

View File

@ -1,4 +1,4 @@
import { observes } from "discourse-common/utils/decorators";
import { action } from "@ember/object";
import { drawHeader } from "wizard/lib/preview";
import WizardPreviewBaseComponent from "./wizard-preview-base";
@ -7,13 +7,23 @@ export default WizardPreviewBaseComponent.extend({
height: 100,
image: null,
@observes("field.value")
didInsertElement() {
this._super(...arguments);
this.field.addListener(this.imageChanged);
},
willDestroyElement() {
this._super(...arguments);
this.field.removeListener(this.imageChanged);
},
@action
imageChanged() {
this.reload();
},
images() {
return { image: this.get("field.value") };
return { image: this.field.value };
},
paint({ ctx, colors, font, width, height }) {

View File

@ -22,12 +22,16 @@ export default WizardPreviewBaseComponent.extend({
init() {
this._super(...arguments);
this.wizard.on("homepageStyleChanged", this.onHomepageStyleChange);
this.step
.findField("homepage_style")
?.addListener(this.onHomepageStyleChange);
},
willDestroy() {
this._super(...arguments);
this.wizard.off("homepageStyleChanged", this.onHomepageStyleChange);
this.step
.findField("homepage_style")
?.removeListener(this.onHomepageStyleChange);
},
didInsertElement() {
@ -104,7 +108,7 @@ export default WizardPreviewBaseComponent.extend({
images() {
return {
logo: this.wizard.getLogoUrl(),
logo: this.wizard.logoUrl,
avatar: "/images/wizard/trout.png",
};
},

View File

@ -27,9 +27,5 @@ export default Component.extend({
@action
onChangeValue(value) {
this.set("field.value", value);
if (this.field.id === "homepage_style") {
this.wizard.trigger("homepageStyleChanged");
}
},
});

View File

@ -30,7 +30,7 @@ export default Component.extend({
},
setupUploads() {
const id = this.get("field.id");
const id = this.field.id;
this._uppyInstance = new Uppy({
id: `wizard-field-image-${id}`,
meta: { upload_type: `wizard_${id}` },

View File

@ -32,8 +32,8 @@
}}</div>
{{/if}}
{{#if this.field.extra_description}}
{{#if this.field.extraDescription}}
<div class="wizard-container__description extra">{{html-safe
this.field.extra_description
this.field.extraDescription
}}</div>
{{/if}}

View File

@ -1,11 +1,11 @@
/*eslint no-bitwise:0 */
import Component from "@ember/component";
import { action } from "@ember/object";
import { scheduleOnce } from "@ember/runloop";
import { htmlSafe } from "@ember/template";
import { Promise } from "rsvp";
import PreloadStore from "discourse/lib/preload-store";
/*eslint no-bitwise:0 */
import getUrl from "discourse-common/lib/get-url";
import { observes } from "discourse-common/utils/decorators";
import { darkLightDiff, drawHeader } from "wizard/lib/preview";
export const LOREM = `
@ -61,22 +61,47 @@ export default Component.extend({
const c = this.element.querySelector("canvas");
this.ctx = c.getContext("2d");
this.ctx.scale(scale, scale);
if (this.step) {
this.step.findField("color_scheme")?.addListener(this.themeChanged);
this.step.findField("homepage_style")?.addListener(this.themeChanged);
this.step.findField("body_font")?.addListener(this.themeBodyFontChanged);
this.step
.findField("heading_font")
?.addListener(this.themeHeadingFontChanged);
}
this.reload();
},
@observes("step.fieldsById.{color_scheme,homepage_style}.value")
willDestroyElement() {
this._super(...arguments);
if (this.step) {
this.step.findField("color_scheme")?.removeListener(this.themeChanged);
this.step.findField("homepage_style")?.removeListener(this.themeChanged);
this.step
.findField("body_font")
?.removeListener(this.themeBodyFontChanged);
this.step
.findField("heading_font")
?.removeListener(this.themeHeadingFontChanged);
}
},
@action
themeChanged() {
this.triggerRepaint();
},
@observes("step.fieldsById.{body_font}.value")
@action
themeBodyFontChanged() {
if (!this.loadingFontVariants) {
this.loadFontVariants(this.wizard.font);
}
},
@observes("step.fieldsById.{heading_font}.value")
@action
themeHeadingFontChanged() {
if (!this.loadingFontVariants) {
this.loadFontVariants(this.wizard.headingFont);

View File

@ -33,7 +33,7 @@
{{#if this.includeSidebar}}
<div class="wizard-container__sidebar">
{{#each this.step.fields as |field|}}
{{#if field.show_in_sidebar}}
{{#if field.showInSidebar}}
<WizardField
@field={{field}}
@step={{this.step}}
@ -45,7 +45,7 @@
{{/if}}
<div class="wizard-container__fields">
{{#each this.step.fields as |field|}}
{{#unless field.show_in_sidebar}}
{{#unless field.showInSidebar}}
<WizardField
@field={{field}}
@step={{this.step}}

View File

@ -4,13 +4,9 @@ import { schedule } from "@ember/runloop";
import { inject as service } from "@ember/service";
import $ from "jquery";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
const alreadyWarned = {};
export default Component.extend({
router: service(),
dialog: service(),
classNameBindings: [":wizard-container__step", "stepClass"],
saving: null,
@ -72,7 +68,7 @@ export default Component.extend({
return step;
},
@observes("step.id")
@observes("step")
_stepChanged() {
this.set("saving", false);
this.autoFocus();
@ -90,7 +86,7 @@ export default Component.extend({
@discourseComputed("step.fields")
includeSidebar(fields) {
return !!fields.findBy("show_in_sidebar");
return !!fields.findBy("showInSidebar");
},
autoFocus() {
@ -125,9 +121,8 @@ export default Component.extend({
exitEarly(event) {
event?.preventDefault();
const step = this.step;
step.validate();
if (step.get("valid")) {
if (step.validate()) {
this.set("saving", true);
step
@ -158,23 +153,7 @@ export default Component.extend({
return;
}
const step = this.step;
const result = step.validate();
if (result.warnings.length) {
const unwarned = result.warnings.filter((w) => !alreadyWarned[w]);
if (unwarned.length) {
unwarned.forEach((w) => (alreadyWarned[w] = true));
return this.dialog.confirm({
message: unwarned.map((w) => I18n.t(`wizard.${w}`)).join("\n"),
didConfirm: () => this.advance(),
});
}
}
if (step.get("valid")) {
if (this.step.validate()) {
this.advance();
} else {
this.autoFocus();

View File

@ -11,7 +11,7 @@ export default Controller.extend({
@action
goNext(response) {
const next = this.get("step.next");
const next = this.step.next;
if (response?.refresh_required) {
document.location = getUrl(`/wizard/steps/${next}`);

View File

@ -1,36 +0,0 @@
import discourseComputed from "discourse-common/utils/decorators";
export const States = {
UNCHECKED: 0,
INVALID: 1,
VALID: 2,
};
export default {
_validState: null,
errorDescription: null,
init() {
this._super(...arguments);
this.set("_validState", States.UNCHECKED);
},
@discourseComputed("_validState")
valid: (state) => state === States.VALID,
@discourseComputed("_validState")
invalid: (state) => state === States.INVALID,
@discourseComputed("_validState")
unchecked: (state) => state === States.UNCHECKED,
setValid(valid, description) {
this.set("_validState", valid ? States.VALID : States.INVALID);
if (!valid && description && description.length) {
this.set("errorDescription", description);
} else {
this.set("errorDescription", null);
}
},
};

View File

@ -1,59 +0,0 @@
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
import ValidState from "wizard/mixins/valid-state";
export default EmberObject.extend(ValidState, {
id: null,
@discourseComputed("index")
displayIndex(index) {
return index + 1;
},
@discourseComputed("fields.[]")
fieldsById(fields) {
const lookup = {};
fields.forEach((field) => (lookup[field.get("id")] = field));
return lookup;
},
validate() {
let allValid = true;
const result = { warnings: [] };
this.fields.forEach((field) => {
allValid = allValid && field.check();
const warning = field.get("warning");
if (warning) {
result.warnings.push(warning);
}
});
this.setValid(allValid);
return result;
},
fieldError(id, description) {
const field = this.fields.findBy("id", id);
if (field) {
field.setValid(false, description);
}
},
save() {
const fields = {};
this.fields.forEach((f) => (fields[f.id] = f.value));
return ajax({
url: `/wizard/steps/${this.id}`,
type: "PUT",
data: { fields },
}).catch((error) => {
error.jqXHR.responseJSON.errors.forEach((err) =>
this.fieldError(err.field, err.description)
);
});
},
});

View File

@ -1,23 +0,0 @@
import EmberObject from "@ember/object";
import ValidState from "wizard/mixins/valid-state";
export default EmberObject.extend(ValidState, {
id: null,
type: null,
value: null,
required: null,
warning: null,
check() {
if (!this.required) {
this.setValid(true);
return true;
}
const val = this.value;
const valid = val && val.length > 0;
this.setValid(valid);
return valid;
},
});

View File

@ -1,64 +1,271 @@
import EmberObject from "@ember/object";
import { readOnly } from "@ember/object/computed";
import Evented from "@ember/object/evented";
import { tracked } from "@glimmer/tracking";
import { ajax } from "discourse/lib/ajax";
import Step from "wizard/models/step";
import WizardField from "wizard/models/wizard-field";
const Wizard = EmberObject.extend(Evented, {
totalSteps: readOnly("steps.length"),
export default class Wizard {
static async load() {
return Wizard.parse((await ajax({ url: "/wizard.json" })).wizard);
}
getTitle() {
const titleStep = this.steps.findBy("id", "forum-title");
if (!titleStep) {
return;
}
return titleStep.get("fieldsById.title.value");
},
static parse({ current_color_scheme, steps, ...payload }) {
return new Wizard({
...payload,
currentColorScheme: current_color_scheme,
steps: steps.map((step) => Step.parse(step)),
});
}
getLogoUrl() {
const logoStep = this.steps.findBy("id", "logos");
if (!logoStep) {
return;
}
return logoStep.get("fieldsById.logo.value");
},
constructor(payload) {
safeAssign(this, payload, [
"start",
"completed",
"steps",
"currentColorScheme",
]);
}
get totalSteps() {
return this.steps.length;
}
get title() {
return this.findStep("forum-tile")?.valueFor("title");
}
get logoUrl() {
return this.findStep("logos")?.valueFor("logo");
}
get currentColors() {
const colorStep = this.steps.findBy("id", "styling");
if (!colorStep) {
return this.current_color_scheme;
const step = this.findStep("styling");
if (!step) {
return this.currentColorScheme;
}
const themeChoice = colorStep.fieldsById.color_scheme;
if (!themeChoice) {
return;
}
const field = step.findField("color_scheme");
return themeChoice.choices?.findBy("id", themeChoice.value)?.data.colors;
},
return field?.chosen?.data.colors;
}
get font() {
const fontChoice = this.steps.findBy("id", "styling")?.fieldsById
?.body_font;
return fontChoice.choices?.findBy("id", fontChoice.value);
},
return this.findStep("styling")?.findField("body_font").chosen;
}
get headingFont() {
const fontChoice = this.steps.findBy("id", "styling")?.fieldsById
?.heading_font;
return fontChoice.choices?.findBy("id", fontChoice.value);
},
});
return this.findStep("styling")?.findField("heading_font").chosen;
}
export function findWizard() {
return ajax({ url: "/wizard.json" }).then(({ wizard }) => {
wizard.steps = wizard.steps.map((step) => {
const stepObj = Step.create(step);
stepObj.fields = stepObj.fields.map((f) => WizardField.create(f));
return stepObj;
});
return Wizard.create(wizard);
});
findStep(id) {
return this.steps.find((step) => step.id === id);
}
}
const ValidStates = {
UNCHECKED: 0,
INVALID: 1,
VALID: 2,
};
export class Step {
static parse({ fields, ...payload }) {
return new Step({
...payload,
fields: fields.map((field) => Field.parse(field)),
});
}
@tracked _validState = ValidStates.UNCHECKED;
constructor(payload) {
safeAssign(this, payload, [
"id",
"next",
"previous",
"description",
"title",
"index",
"banner",
"emoji",
"fields",
]);
}
get valid() {
return this._validState === ValidStates.VALID;
}
set valid(valid) {
this._validState = valid ? ValidStates.VALID : ValidStates.INVALID;
}
get invalid() {
return this._validState === ValidStates.INVALID;
}
get unchecked() {
return this._validState === ValidStates.UNCHECKED;
}
get displayIndex() {
return this.index + 1;
}
valueFor(id) {
return this.findField(id)?.value;
}
findField(id) {
return this.fields.find((field) => field.id === id);
}
fieldError(id, description) {
let field = this.findField(id);
if (field) {
field.errorDescription = description;
}
}
validate() {
let valid = this.fields
.map((field) => field.validate())
.every((result) => result);
return (this.valid = valid);
}
serialize() {
let data = {};
for (let field of this.fields) {
data[field.id] = field.value;
}
return data;
}
async save() {
try {
return await ajax({
url: `/wizard/steps/${this.id}`,
type: "PUT",
data: { fields: this.serialize() },
});
} catch (error) {
for (let err of error.jqXHR.responseJSON.errors) {
this.fieldError(err.field, err.description);
}
}
}
}
export class Field {
static parse({ extra_description, show_in_sidebar, choices, ...payload }) {
return new Field({
...payload,
extraDescription: extra_description,
showInSidebar: show_in_sidebar,
choices: choices?.map((choice) => Choice.parse(choice)),
});
}
@tracked _value = null;
@tracked _validState = ValidStates.UNCHECKED;
@tracked _errorDescription = null;
_listeners = [];
constructor(payload) {
safeAssign(this, payload, [
"id",
"type",
"required",
"value",
"label",
"placeholder",
"description",
"extraDescription",
"icon",
"disabled",
"showInSidebar",
"choices",
]);
}
get value() {
return this._value;
}
set value(newValue) {
this._value = newValue;
for (let listener of this._listeners) {
listener();
}
}
get chosen() {
return this.choices?.find((choice) => choice.id === this.value);
}
get valid() {
return this._validState === ValidStates.VALID;
}
set valid(valid) {
this._validState = valid ? ValidStates.VALID : ValidStates.INVALID;
this._errorDescription = null;
}
get invalid() {
return this._validState === ValidStates.INVALID;
}
get unchecked() {
return this._validState === ValidStates.UNCHECKED;
}
get errorDescription() {
return this._errorDescription;
}
set errorDescription(description) {
this._validState = ValidStates.INVALID;
this._errorDescription = description;
}
validate() {
let valid = true;
if (this.required) {
valid = !!(this.value?.length > 0);
}
return (this.valid = valid);
}
addListener(listener) {
this._listeners.push(listener);
}
removeListener(listener) {
this._listeners = this._listeners.filter((l) => l === listener);
}
}
export class Choice {
static parse({ extra_label, ...payload }) {
return new Choice({ ...payload, extraLabel: extra_label });
}
constructor({ id, label, extraLabel, description, icon, data }) {
Object.assign(this, { id, label, extraLabel, description, icon, data });
}
}
function safeAssign(object, payload, permittedKeys) {
for (const [key, value] of Object.entries(payload)) {
if (permittedKeys.includes(key)) {
object[key] = value;
}
}
}

View File

@ -1,10 +1,10 @@
import Route from "@ember/routing/route";
import DisableSidebar from "discourse/mixins/disable-sidebar";
import { findWizard } from "wizard/models/wizard";
import Wizard from "wizard/models/wizard";
export default Route.extend(DisableSidebar, {
model() {
return findWizard();
return Wizard.load();
},
activate() {