FEATURE: Improve wizard quality and rearrange steps (#30055)

This commit contains various quality improvements to
our site setup wizard, along with some rearrangement of
steps to improve the admin setup experience and encourage
admins to customize the site early to avoid "all sites look the
same" sentiment.

#### Step rearrangement

* “Your site is ready” from 3 → 4
* “Logos” from 4 → 5
* “Look and feel” from 5 → 3

#### Font selector improvements

Changes the wizard font selector dropdown to show
a preview of all fonts with a CSS class so you don't
have to choose the font to get a preview.

Also makes the fonts appear in alphabetical order.

#### Preview improvements

Placeholder text changed from lorem ipsum to actual topic titles,
category names, and post content. This makes it feel more "real".

Fixes "undefined" categories. Added a date to the topic timeline.

Fixes button rectangles and other UI elements not changing in
size when the font changed, leading to cut off text which looked super
messy. Also fixed some font color issues.

Fixed table header alignment for Latest topic list.

#### Homepage style selector improvements

Limited the big list of homepage styles to Latest, Hot, Categories with latest topics,
and Category boxes based on research into the most common options.

#### Preview header

Changed the preview header to move the hamburger to the left
and add a chat icon

#### And more!

Changed the background of the wizard to use our branded blob style.
This commit is contained in:
Martin Brennan 2025-01-02 09:28:23 +10:00 committed by GitHub
parent c2282439b3
commit 3135f472e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1090 additions and 297 deletions

View File

@ -0,0 +1,73 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
import { action, set } from "@ember/object";
import ColorPalettes from "select-kit/components/color-palettes";
import ComboBox from "select-kit/components/combo-box";
import FontSelector from "select-kit/components/font-selector";
import HomepageStyleSelector from "select-kit/components/homepage-style-selector";
export default class Dropdown extends Component {
constructor() {
super(...arguments);
if (this.args.field.id === "color_scheme") {
for (let choice of this.args.field.choices) {
if (choice?.data?.colors) {
set(choice, "colors", choice.data.colors);
}
}
}
if (this.args.field.id === "body_font") {
for (let choice of this.args.field.choices) {
set(choice, "classNames", `body-font-${choice.id.replace(/_/g, "-")}`);
}
}
if (this.args.field.id === "heading_font") {
for (let choice of this.args.field.choices) {
set(
choice,
"classNames",
`heading-font-${choice.id.replace(/_/g, "-")}`
);
}
}
}
get component() {
switch (this.args.field.id) {
case "color_scheme":
return ColorPalettes;
case "body_font":
case "heading_font":
return FontSelector;
case "homepage_style":
return HomepageStyleSelector;
default:
return ComboBox;
}
}
keyPress(event) {
event.stopPropagation();
}
@action
onChangeValue(value) {
this.set("field.value", value);
}
<template>
{{component
this.component
class="wizard-container__dropdown"
value=@field.value
content=@field.choices
nameProperty="label"
tabindex="9"
onChange=this.onChangeValug
options=(hash translatedNone=false)
}}
</template>
}

View File

@ -1,10 +0,0 @@
{{component
this.component
class="wizard-container__dropdown"
value=this.field.value
content=this.field.choices
nameProperty="label"
tabindex="9"
onChange=(action "onChangeValue")
options=(hash translatedNone=false)
}}

View File

@ -1,33 +0,0 @@
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 class Dropdown extends Component {
init() {
super.init(...arguments);
if (this.field.id === "color_scheme") {
for (let choice of this.field.choices) {
if (choice?.data?.colors) {
set(choice, "colors", choice.data.colors);
}
}
}
}
@discourseComputed("field.id")
component(id) {
return id === "color_scheme" ? ColorPalettes : ComboBox;
}
keyPress(e) {
e.stopPropagation();
}
@action
onChangeValue(value) {
this.set("field.value", value);
}
}

View File

@ -1,5 +1,6 @@
import { action } from "@ember/object";
import { drawHeader, LOREM } from "../../../lib/preview";
import { i18n } from "discourse-i18n";
import { drawHeader } from "../../../lib/preview";
import PreviewBaseComponent from "../styling-preview/-preview-base";
export default class LogoSmall extends PreviewBaseComponent {
@ -34,10 +35,10 @@ export default class LogoSmall extends PreviewBaseComponent {
const image = this.image;
const headerMargin = headerHeight * 0.2;
const maxWidth = headerHeight - headerMargin * 2.0;
let imageWidth = image.width;
let ratio = 1.0;
if (imageWidth > maxWidth) {
ratio = maxWidth / imageWidth;
imageWidth = maxWidth;
@ -52,38 +53,42 @@ export default class LogoSmall extends PreviewBaseComponent {
);
const afterLogo = headerMargin * 1.7 + imageWidth;
const fontSize = Math.round(headerHeight * 0.4);
const fontSize = Math.round(headerHeight * 0.3);
ctx.font = `Bold ${fontSize}px '${headingFont}'`;
ctx.fillStyle = colors.primary;
const title = LOREM.substring(0, 27);
const title = i18n("wizard.homepage_preview.topic_titles.what_books");
ctx.fillText(
title,
headerMargin + imageWidth,
headerHeight - fontSize * 1.1
headerMargin + imageWidth + 10,
headerHeight - fontSize * 1.8
);
const category = this.categories()[0];
const badgeSize = height / 13.0;
ctx.beginPath();
ctx.fillStyle = category.color;
ctx.rect(afterLogo, headerHeight * 0.7, badgeSize, badgeSize);
ctx.rect(afterLogo + 2, headerHeight * 0.6, badgeSize, badgeSize);
ctx.fill();
ctx.font = `Bold ${badgeSize * 1.2}px '${font}'`;
ctx.fillStyle = colors.primary;
ctx.fillText(
category.displayName,
category.name,
afterLogo + badgeSize * 1.5,
headerHeight * 0.7 + badgeSize * 0.9
headerHeight * 0.6 + badgeSize * 0.9
);
const LINE_HEIGHT = 12;
ctx.font = `${LINE_HEIGHT}px '${font}'`;
const lines = LOREM.split("\n");
for (let i = 0; i < 10; i++) {
const line = height * 0.55 + i * (LINE_HEIGHT * 1.5);
ctx.fillText(lines[i], afterLogo, line);
const opFirstSentenceLines = i18n(
"wizard.homepage_preview.topic_ops.what_books"
)
.split(".")[0]
.split("\n");
for (let i = 0; i < 2; i++) {
const line = height * 0.7 + i * (LINE_HEIGHT * 1.5);
ctx.fillText(opFirstSentenceLines[i], afterLogo, line);
}
}
}

View File

@ -1,6 +1,9 @@
<label
class="wizard-container__button wizard-container__button-upload
{{if this.uploading 'disabled'}}"
class={{concatClass
"wizard-container__button wizard-container__button-upload"
(if this.uploading "disabled")
(if this.hasUpload "has-upload")
}}
>
{{#if this.uploading}}
{{i18n "wizard.uploading"}}

View File

@ -27,6 +27,14 @@ export default class Image extends Component {
this.setupUploads();
}
@discourseComputed("uploading", "field.value")
hasUpload() {
return (
!this.uploading &&
!this.field.value.includes("discourse-logo-sketch-small.png")
);
}
setupUploads() {
const id = this.field.id;
this._uppyInstance = new Uppy({

View File

@ -32,37 +32,38 @@ export default class Radio extends Component {
<template>
<div class="wizard-container__radio-choices">
{{#each @field.choices as |c|}}
{{#each @field.choices as |choice|}}
<div
class={{concatClass
"wizard-container__radio-choice"
(if c.selected "--selected")
(if choice.selected "--selected")
}}
data-choice-id={{choice.id}}
>
<label class="wizard-container__label">
<PluginOutlet
@name="wizard-radio"
@outletArgs={{hash disabled=c.disabled}}
@outletArgs={{hash disabled=choice.disabled}}
>
<input
type="radio"
value={{c.id}}
value={{choice.id}}
class="wizard-container__radio"
disabled={{c.disabled}}
checked={{c.selected}}
disabled={{choice.disabled}}
checked={{choice.selected}}
{{on "change" (withEventValue this.selectionChanged)}}
/>
<span class="wizard-container__radio-label">
{{#if c.icon}}
{{icon c.icon}}
{{#if choice.icon}}
{{icon choice.icon}}
{{/if}}
<span>{{c.label}}</span>
<span>{{choice.label}}</span>
</span>
</PluginOutlet>
<PluginOutlet
@name="below-wizard-radio"
@outletArgs={{hash disabled=c.disabled}}
@outletArgs={{hash disabled=choice.disabled}}
/>
</label>
</div>

View File

@ -1,4 +1,5 @@
import { darkLightDiff, LOREM } from "../../../lib/preview";
import { i18n } from "discourse-i18n";
import { darkLightDiff } from "../../../lib/preview";
import PreviewBaseComponent from "./-preview-base";
export default class HomepagePreview extends PreviewBaseComponent {
@ -25,26 +26,26 @@ export default class HomepagePreview extends PreviewBaseComponent {
const homepageStyle = this.getHomepageStyle();
if (homepageStyle === "latest") {
this.drawPills(colors, font, height * 0.15);
this.renderLatest(ctx, colors, font, width, height);
if (homepageStyle === "latest" || homepageStyle === "hot") {
this.drawPills(colors, font, height * 0.15, { homepageStyle });
this.renderNonCategoryHomepage(ctx, colors, font, width, height);
} else if (
["categories_only", "categories_with_featured_topics"].includes(
homepageStyle
)
) {
this.drawPills(colors, font, height * 0.15, { categories: true });
this.drawPills(colors, font, height * 0.15, { homepageStyle });
this.renderCategories(ctx, colors, font, width, height);
} else if (
["categories_boxes", "categories_boxes_with_topics"].includes(
homepageStyle
)
) {
this.drawPills(colors, font, height * 0.15, { categories: true });
this.drawPills(colors, font, height * 0.15, { homepageStyle });
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 });
this.drawPills(colors, font, height * 0.15, { homepageStyle });
this.renderCategoriesWithTopics(ctx, colors, font, width, height);
}
}
@ -82,14 +83,10 @@ export default class HomepagePreview extends PreviewBaseComponent {
]
);
ctx.font = `Bold ${bodyFontSize * 1.3}em '${font}'`;
ctx.font = `700 ${bodyFontSize * 1.3}em '${font}'`;
ctx.fillStyle = colors.primary;
ctx.textAlign = "center";
ctx.fillText(
category.displayName,
boxStartX + boxWidth / 2,
boxStartY + 25
);
ctx.fillText(category.name, boxStartX + boxWidth / 2, boxStartY + 25);
ctx.textAlign = "left";
if (opts.topics) {
@ -167,16 +164,16 @@ export default class HomepagePreview extends PreviewBaseComponent {
drawLine(width / 2, y);
// Categories
this.categories().forEach((category) => {
this.categories().forEach((category, idx) => {
const textPos = y + categoryHeight * 0.35;
ctx.font = `Bold ${bodyFontSize * 1.1}em '${font}'`;
ctx.font = `700 ${bodyFontSize * 1.1}em '${font}'`;
ctx.fillStyle = colors.primary;
ctx.fillText(category.displayName, cols[0], textPos);
ctx.fillText(category.name, cols[0], textPos);
ctx.font = `${bodyFontSize * 0.8}em '${font}'`;
ctx.fillStyle = textColor;
ctx.fillText(
titles[0],
titles[idx],
cols[0] - margin * 0.25,
textPos + categoryHeight * 0.36
);
@ -263,16 +260,16 @@ export default class HomepagePreview extends PreviewBaseComponent {
const titles = this.getTitles();
// Categories
this.categories().forEach((category) => {
this.categories().forEach((category, idx) => {
const textPos = y + categoryHeight * 0.35;
ctx.font = `Bold ${bodyFontSize * 1.1}em '${font}'`;
ctx.font = `700 ${bodyFontSize * 1.1}em '${font}'`;
ctx.fillStyle = colors.primary;
ctx.fillText(category.displayName, cols[0], textPos);
ctx.fillText(category.name, cols[0], textPos);
ctx.font = `${bodyFontSize * 0.8}em '${font}'`;
ctx.fillStyle = textColor;
ctx.fillText(
titles[0],
titles[idx],
cols[0] - margin * 0.25,
textPos + categoryHeight * 0.36
);
@ -303,7 +300,7 @@ export default class HomepagePreview extends PreviewBaseComponent {
const category = this.categories()[0];
ctx.font = `${bodyFontSize}em '${font}'`;
const textPos = y + topicHeight * 0.45;
ctx.fillStyle = textColor;
ctx.fillStyle = colors.primary;
this.scaleImage(
this.avatar,
cols[2],
@ -313,7 +310,7 @@ export default class HomepagePreview extends PreviewBaseComponent {
);
ctx.fillText(title, cols[3], textPos);
ctx.font = `Bold ${bodyFontSize}em '${font}'`;
ctx.font = `700 ${bodyFontSize}em '${font}'`;
ctx.fillText(Math.floor(Math.random() * 90) + 10, cols[4], textPos);
ctx.font = `${bodyFontSize}em '${font}'`;
ctx.fillText(`1h`, cols[4], textPos + topicHeight * 0.4);
@ -321,9 +318,9 @@ export default class HomepagePreview extends PreviewBaseComponent {
ctx.beginPath();
ctx.fillStyle = category.color;
const badgeSize = topicHeight * 0.1;
ctx.font = `Bold ${bodyFontSize * 0.5}em '${font}'`;
ctx.font = `700 ${bodyFontSize * 0.5}em '${font}'`;
ctx.rect(
cols[3] + margin * 0.5,
cols[3] + margin * 0.25,
y + topicHeight * 0.65,
badgeSize,
badgeSize
@ -332,8 +329,8 @@ export default class HomepagePreview extends PreviewBaseComponent {
ctx.fillStyle = colors.primary;
ctx.fillText(
category.displayName,
cols[3] + badgeSize * 3,
category.name,
cols[3] + badgeSize * 2,
y + topicHeight * 0.76
);
y += topicHeight;
@ -347,16 +344,23 @@ export default class HomepagePreview extends PreviewBaseComponent {
}
getTitles() {
return LOREM.split(".")
.slice(0, 8)
.map((t) => t.substring(0, 40));
return [
i18n("wizard.homepage_preview.topic_titles.what_books"),
i18n("wizard.homepage_preview.topic_titles.what_movies"),
i18n("wizard.homepage_preview.topic_titles.random_fact"),
i18n("wizard.homepage_preview.topic_titles.tv_show"),
];
}
getDescriptions() {
return LOREM.split(".");
return [
i18n("wizard.homepage_preview.category_descriptions.icebreakers"),
i18n("wizard.homepage_preview.category_descriptions.news"),
i18n("wizard.homepage_preview.category_descriptions.site_feedback"),
];
}
renderLatest(ctx, colors, font, width, height) {
renderNonCategoryHomepage(ctx, colors, font, width, height) {
const rowHeight = height / 6.6;
// accounts for hard-set color variables in solarized themes
const textColor =
@ -379,17 +383,33 @@ export default class HomepagePreview extends PreviewBaseComponent {
ctx.stroke();
};
const cols = [0.02, 0.66, 0.8, 0.87, 0.93].map((c) => c * width);
const cols = [0.02, 0.66, 0.75, 0.83, 0.9].map((c) => c * width);
// Headings
const headingY = height * 0.33;
ctx.fillStyle = textColor;
ctx.font = `${bodyFontSize * 0.9}em '${font}'`;
ctx.fillText("Topic", cols[0], headingY);
ctx.fillText("Replies", cols[2], headingY);
ctx.fillText("Views", cols[3], headingY);
ctx.fillText("Activity", cols[4], headingY);
ctx.fillText(
i18n("wizard.homepage_preview.table_headers.topic"),
cols[0],
headingY
);
ctx.fillText(
i18n("wizard.homepage_preview.table_headers.replies"),
cols[2],
headingY
);
ctx.fillText(
i18n("wizard.homepage_preview.table_headers.views"),
cols[3],
headingY
);
ctx.fillText(
i18n("wizard.homepage_preview.table_headers.activity"),
cols[4],
headingY
);
// Topics
let y = headingY + rowHeight / 2.6;
@ -400,20 +420,21 @@ export default class HomepagePreview extends PreviewBaseComponent {
ctx.lineWidth = 1;
this.getTitles().forEach((title) => {
const textPos = y + rowHeight * 0.4;
ctx.fillStyle = textColor;
ctx.fillStyle = colors.primary;
ctx.fillText(title, cols[0], textPos);
// Category badge
const category = this.categories()[0];
ctx.beginPath();
ctx.fillStyle = category.color;
const badgeSize = rowHeight * 0.15;
ctx.font = `Bold ${bodyFontSize * 0.75}em '${font}'`;
ctx.font = `700 ${bodyFontSize * 0.75}em '${font}'`;
ctx.rect(cols[0] + 4, y + rowHeight * 0.6, badgeSize, badgeSize);
ctx.fill();
ctx.fillStyle = colors.primary;
ctx.fillText(
category.displayName,
category.name,
cols[0] + badgeSize * 2,
y + rowHeight * 0.73
);

View File

@ -6,18 +6,9 @@ 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 { i18n } from "discourse-i18n";
import { darkLightDiff, drawHeader } from "../../../lib/preview";
export const LOREM = `
Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
Nullam eget sem non elit
tincidunt rhoncus. Fusce
velit nisl, porttitor sed
nisl ac, consectetur interdum
metus. Fusce in consequat
augue, vel facilisis felis.`;
const scaled = {};
function canvasFor(image, w, h) {
@ -125,7 +116,7 @@ export default class PreviewBase extends Component {
});
});
Promise.all(
return Promise.all(
fontFaces.map((fontFace) =>
fontFace.load().then((loadedFont) => {
document.fonts.add(loadedFont);
@ -144,7 +135,7 @@ export default class PreviewBase extends Component {
this.loadingFontVariants = false;
});
} else if (this.loadedFonts.has(font.id)) {
this.triggerRepaint();
return Promise.resolve(this.triggerRepaint());
}
}
@ -171,8 +162,14 @@ export default class PreviewBase extends Component {
reload() {
Promise.all([this.loadFonts(), this.loadImages()]).then(() => {
this.loaded = true;
this.triggerRepaint();
// NOTE: This must be done otherwise the "bold" variant of the body font
// will not be loaded for some reason before rendering the canvas.
//
// The header font does not suffer from this issue.
this.loadFontVariants(this.wizard.font).then(() => {
this.loaded = true;
this.triggerRepaint();
});
});
}
@ -219,9 +216,18 @@ export default class PreviewBase extends Component {
categories() {
return [
{ name: "consecteteur", color: "#652D90" },
{ name: "ultrices", color: "#3AB54A" },
{ name: "placerat", color: "#25AAE2" },
{
name: i18n("wizard.homepage_preview.category_names.icebreakers"),
color: "#652D90",
},
{
name: i18n("wizard.homepage_preview.category_names.news"),
color: "#3AB54A",
},
{
name: i18n("wizard.homepage_preview.category_names.site_feedback"),
color: "#25AAE2",
},
];
}
@ -248,17 +254,20 @@ export default class PreviewBase extends Component {
ctx.drawImage(scaled[key], x, y, w, h);
}
get headerHeight() {
return this.height * 0.15;
}
drawFullHeader(colors, font, logo) {
const { ctx } = this;
const headerHeight = this.height * 0.15;
drawHeader(ctx, colors, this.width, headerHeight);
drawHeader(ctx, colors, this.width, this.headerHeight);
const avatarSize = this.height * 0.1;
const headerMargin = headerHeight * 0.2;
const headerMargin = this.headerHeight * 0.2;
if (logo) {
const logoHeight = headerHeight - headerMargin * 2;
const logoHeight = this.headerHeight - headerMargin * 2;
const ratio = logoHeight / logo.height;
this.scaleImage(
@ -280,23 +289,25 @@ export default class PreviewBase extends Component {
avatarSize,
avatarSize
);
// accounts for hard-set color variables in solarized themes
ctx.fillStyle =
colors.primary_low_mid ||
darkLightDiff(colors.primary, colors.secondary, 45, 55);
const pathScale = headerHeight / 1200;
// search icon SVG path
const pathScale = this.headerHeight / 1200;
const searchIcon = new Path2D(
"M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"
);
// hamburger icon
const hamburgerIcon = new Path2D(
"M16 132h416c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H16C7.163 60 0 67.163 0 76v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h416c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H16c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z"
);
const chatIcon = new Path2D(
"M512 240c0 114.9-114.6 208-256 208c-37.1 0-72.3-6.4-104.1-17.9c-11.9 8.7-31.3 20.6-54.3 30.6C73.6 471.1 44.7 480 16 480c-6.5 0-12.3-3.9-14.8-9.9c-2.5-6-1.1-12.8 3.4-17.4c0 0 0 0 0 0s0 0 0 0s0 0 0 0c0 0 0 0 0 0l.3-.3c.3-.3 .7-.7 1.3-1.4c1.1-1.2 2.8-3.1 4.9-5.7c4.1-5 9.6-12.4 15.2-21.6c10-16.6 19.5-38.4 21.4-62.9C17.7 326.8 0 285.1 0 240C0 125.1 114.6 32 256 32s256 93.1 256 208z"
);
ctx.save(); // Save the previous state for translation and scale
ctx.translate(
this.width - avatarSize * 3 - headerMargin * 0.5,
this.width - avatarSize * 2 - headerMargin * 0.5,
avatarSize / 2
);
// need to scale paths otherwise they're too large
@ -305,10 +316,15 @@ export default class PreviewBase extends Component {
ctx.restore();
ctx.save();
ctx.translate(
this.width - avatarSize * 2 - headerMargin * 0.5,
this.width - avatarSize * 3 - headerMargin * 0.5,
avatarSize / 2
);
ctx.scale(pathScale, pathScale);
ctx.fill(chatIcon);
ctx.restore();
ctx.save();
ctx.translate(headerMargin * 1.75, avatarSize / 2);
ctx.scale(pathScale, pathScale);
ctx.fill(hamburgerIcon);
ctx.restore();
}
@ -318,77 +334,109 @@ export default class PreviewBase extends Component {
const { ctx } = this;
const categoriesSize = headerHeight * 2;
const badgeHeight = categoriesSize * 0.25;
const badgeHeight = headerHeight * 2 * 0.25;
const headerMargin = headerHeight * 0.2;
const fontSize = Math.round(badgeHeight * 0.5);
ctx.font = `${fontSize}px '${font}'`;
const allCategoriesText = i18n(
"wizard.homepage_preview.nav_buttons.all_categories"
);
const categoriesWidth = ctx.measureText(allCategoriesText).width;
const categoriesBoxWidth = categoriesWidth + headerMargin * 2;
// Box around "all categories >"
ctx.beginPath();
ctx.strokeStyle = colors.primary;
ctx.lineWidth = 0.5;
ctx.rect(
headerMargin,
headerHeight + headerMargin,
categoriesSize,
categoriesBoxWidth,
badgeHeight
);
ctx.stroke();
const fontSize = Math.round(badgeHeight * 0.5);
ctx.font = `${fontSize}px '${font}'`;
ctx.fillStyle = colors.primary;
ctx.fillText(
"all categories",
allCategoriesText,
headerMargin * 1.5,
headerHeight + headerMargin * 1.4 + fontSize
);
// Caret (>) at the end of "all categories" box
const pathScale = badgeHeight / 1000;
// caret icon
const caretIcon = new Path2D(
"M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"
);
ctx.save();
ctx.translate(
categoriesSize - headerMargin / 4,
categoriesBoxWidth,
headerHeight + headerMargin + badgeHeight / 4
);
ctx.scale(pathScale, pathScale);
ctx.fill(caretIcon);
ctx.restore();
const text = opts.categories ? "Categories" : "Latest";
const categoryHomepage =
opts.homepageStyle !== "hot" && opts.homepageStyle !== "latest";
// First top menu item
let otherHomepageText;
switch (opts.homepageStyle) {
case "hot":
otherHomepageText = i18n("wizard.homepage_preview.nav_buttons.hot");
break;
case "latest":
otherHomepageText = i18n("wizard.homepage_preview.nav_buttons.latest");
break;
}
const firstTopMenuItemText = categoryHomepage
? i18n("wizard.homepage_preview.nav_buttons.categories")
: otherHomepageText;
const newText = i18n("wizard.homepage_preview.nav_buttons.new");
const unreadText = i18n("wizard.homepage_preview.nav_buttons.unread");
const topText = i18n("wizard.homepage_preview.nav_buttons.top");
const activeWidth = categoriesSize * (opts.categories ? 0.8 : 0.55);
ctx.beginPath();
ctx.fillStyle = colors.tertiary;
ctx.rect(
headerMargin * 2 + categoriesSize,
categoriesBoxWidth + headerMargin * 2,
headerHeight + headerMargin,
activeWidth,
ctx.measureText(firstTopMenuItemText).width + headerMargin * 2,
badgeHeight
);
ctx.fill();
ctx.font = `${fontSize}px '${font}'`;
ctx.fillStyle = colors.secondary;
let x = headerMargin * 3.0 + categoriesSize;
const pillButtonTextY = headerHeight + headerMargin * 1.4 + fontSize;
const firstTopMenuItemX = headerMargin * 3.0 + categoriesBoxWidth;
ctx.fillText(
text,
x - headerMargin * 0.1,
headerHeight + headerMargin * 1.5 + fontSize
firstTopMenuItemText,
firstTopMenuItemX,
pillButtonTextY,
ctx.measureText(firstTopMenuItemText).width
);
ctx.fillStyle = colors.primary;
x += categoriesSize * (opts.categories ? 0.8 : 0.6);
ctx.fillText("New", x, headerHeight + headerMargin * 1.5 + fontSize);
x += categoriesSize * 0.4;
ctx.fillText("Unread", x, headerHeight + headerMargin * 1.5 + fontSize);
const newTextX =
firstTopMenuItemX +
ctx.measureText(firstTopMenuItemText).width +
headerMargin * 2.0;
ctx.fillText(newText, newTextX, pillButtonTextY);
x += categoriesSize * 0.6;
ctx.fillText("Top", x, headerHeight + headerMargin * 1.5 + fontSize);
const unreadTextX =
newTextX + ctx.measureText(newText).width + headerMargin * 2.0;
ctx.fillText(unreadText, unreadTextX, pillButtonTextY);
const topTextX =
unreadTextX + ctx.measureText(unreadText).width + headerMargin * 2.0;
ctx.fillText(topText, topTextX, pillButtonTextY);
}
}

View File

@ -2,19 +2,17 @@ import { action } from "@ember/object";
import { observes } from "@ember-decorators/object";
import { bind } from "discourse-common/utils/decorators";
import { i18n } from "discourse-i18n";
import { chooseDarker, darkLightDiff } from "../../../lib/preview";
import {
chooseDarker,
darkLightDiff,
resizeTextLinesToFitRect,
} from "../../../lib/preview";
import HomepagePreview from "./-homepage-preview";
import PreviewBaseComponent from "./-preview-base";
const LOREM = `
Lorem ipsum dolor sit amet, consectetur adipiscing.
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 class Index extends PreviewBaseComponent {
width = 628;
height = 322;
width = 630;
height = 380;
logo = null;
avatar = null;
previewTopic = true;
@ -117,79 +115,94 @@ export default class Index extends PreviewBaseComponent {
}
paint({ ctx, colors, font, headingFont, width, height }) {
const headerHeight = height * 0.3;
this.drawFullHeader(colors, headingFont, this.logo);
const margin = 20;
const avatarSize = height * 0.15;
const avatarSize = height * 0.1 + 5;
const lineHeight = height / 14;
const leftHandTextGutter = margin + avatarSize + margin;
const timelineX = width * 0.86;
// Draw a fake topic
this.scaleImage(
this.avatar,
margin,
headerHeight + height * 0.09,
this.headerHeight + height * 0.22,
avatarSize,
avatarSize
);
const titleFontSize = headerHeight / 55;
const titleFontSize = this.headerHeight / 30;
// Topic title
ctx.beginPath();
ctx.fillStyle = colors.primary;
ctx.font = `bold ${titleFontSize}em '${headingFont}'`;
ctx.font = `700 ${titleFontSize}em '${headingFont}'`;
ctx.fillText(i18n("wizard.previews.topic_title"), margin, height * 0.3);
const bodyFontSize = height / 330.0;
// Topic OP text
const bodyFontSize = 1;
ctx.font = `${bodyFontSize}em '${font}'`;
let line = 0;
const lines = LOREM.split("\n");
for (let i = 0; i < 5; i++) {
line = height * 0.35 + i * lineHeight;
ctx.fillText(lines[i], margin + avatarSize + margin, line);
}
let verticalLinePos = 0;
const topicOp = i18n("wizard.homepage_preview.topic_ops.what_books");
const topicOpLines = topicOp.split("\n");
// Share Button
const shareButtonWidth = i18n("wizard.previews.share_button").length * 11;
resizeTextLinesToFitRect(
topicOpLines,
timelineX - leftHandTextGutter,
ctx,
bodyFontSize,
font,
(textLine, idx) => {
verticalLinePos = height * 0.4 + idx * lineHeight;
ctx.fillText(textLine, leftHandTextGutter, verticalLinePos);
}
);
ctx.font = `${bodyFontSize}em '${font}'`;
// Share button
const shareButtonWidth =
Math.round(ctx.measureText(i18n("wizard.previews.share_button")).width) +
margin;
ctx.beginPath();
ctx.rect(margin, line + lineHeight, shareButtonWidth, height * 0.1);
ctx.rect(margin, verticalLinePos, shareButtonWidth, height * 0.1);
// accounts for hard-set color variables in solarized themes
ctx.fillStyle =
colors.primary_low ||
darkLightDiff(colors.primary, colors.secondary, 90, 65);
ctx.fill();
ctx.fillStyle = chooseDarker(colors.primary, colors.secondary);
ctx.font = `${bodyFontSize}em '${font}'`;
ctx.fillText(
i18n("wizard.previews.share_button"),
margin + 10,
line + lineHeight * 1.9
verticalLinePos + lineHeight * 0.9
);
// Reply Button
const replyButtonWidth = i18n("wizard.previews.reply_button").length * 11;
// Reply button
const replyButtonWidth =
Math.round(ctx.measureText(i18n("wizard.previews.reply_button")).width) +
margin;
ctx.beginPath();
ctx.rect(
shareButtonWidth + margin + 10,
line + lineHeight,
verticalLinePos,
replyButtonWidth,
height * 0.1
);
ctx.fillStyle = colors.tertiary;
ctx.fill();
ctx.fillStyle = colors.secondary;
ctx.font = `${bodyFontSize}em '${font}'`;
ctx.fillText(
i18n("wizard.previews.reply_button"),
shareButtonWidth + margin + 20,
line + lineHeight * 1.9
shareButtonWidth + margin * 2,
verticalLinePos + lineHeight * 0.9
);
// Draw Timeline
const timelineX = width * 0.86;
// Draw timeline
ctx.beginPath();
ctx.strokeStyle = colors.tertiary;
ctx.lineWidth = 0.5;
@ -197,17 +210,30 @@ export default class Index extends PreviewBaseComponent {
ctx.lineTo(timelineX, height * 0.7);
ctx.stroke();
// Timeline
// Timeline handle
ctx.beginPath();
ctx.strokeStyle = colors.tertiary;
ctx.lineWidth = 2;
ctx.moveTo(timelineX, height * 0.3);
ctx.lineWidth = 3;
ctx.moveTo(timelineX, height * 0.3 + 10);
ctx.lineTo(timelineX, height * 0.4);
ctx.stroke();
ctx.font = `Bold ${bodyFontSize}em ${font}`;
// Timeline post count
const postCountY = height * 0.3 + margin + 10;
ctx.beginPath();
ctx.font = `700 ${bodyFontSize}em '${font}'`;
ctx.fillStyle = colors.primary;
ctx.fillText("1 / 20", timelineX + margin, height * 0.3 + margin * 1.5);
ctx.fillText("1 / 20", timelineX + margin / 2, postCountY);
// Timeline post date
ctx.beginPath();
ctx.font = `${bodyFontSize * 0.9}em '${font}'`;
ctx.fillStyle = darkLightDiff(colors.primary, colors.secondary, 70, 65);
ctx.fillText(
"Nov 22",
timelineX + margin / 2,
postCountY + lineHeight * 0.75
);
}
@action

View File

@ -1,15 +1,5 @@
/*eslint no-bitwise:0 */
export const LOREM = `
Lorem ipsum dolor sit amet,
consectetur adipiscing elit.
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 function parseColor(color) {
const m = color.match(/^#([0-9a-f]{6})$/i);
if (m) {
@ -148,3 +138,26 @@ export function drawHeader(ctx, colors, width, headerHeight) {
ctx.fill();
ctx.restore();
}
export function resizeTextLinesToFitRect(
textLines,
rectWidth,
ctx,
fontSize,
font,
renderCallback
) {
const maxLengthLine = textLines.reduce((a, b) =>
a.length > b.length ? a : b
);
let fontSizeDecreaseMultiplier = 1;
while (ctx.measureText(maxLengthLine).width > rectWidth) {
fontSizeDecreaseMultiplier -= 0.1;
ctx.font = `${fontSize * fontSizeDecreaseMultiplier}em '${font}'`;
}
for (let i = 0; i < textLines.length; i++) {
renderCallback(textLines[i], i);
}
}

View File

@ -115,6 +115,9 @@ function rowHelper(row) {
el() {
return row;
},
hasClass(className) {
return row.classList.contains(className);
},
};
}

View File

@ -0,0 +1,255 @@
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import Dropdown from "discourse/static/wizard/components/fields/dropdown";
import { Choice, Field } from "discourse/static/wizard/models/wizard";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import selectKit from "discourse/tests/helpers/select-kit-helper";
function buildFontChoices() {
return [
{
id: "arial",
label: "Arial",
classNames: "body-font-arial",
},
{
id: "helvetica",
label: "Helvetica",
classNames: "body-font-helvetica",
},
{
id: "lato",
label: "Lato",
classNames: "body-font-lato",
},
{
id: "montserrat",
label: "Montserrat",
classNames: "body-font-montserrat",
},
{
id: "noto_sans",
label: "NotoSans",
classNames: "body-font-noto-sans",
},
{
id: "roboto",
label: "Roboto",
classNames: "body-font-roboto",
},
{
id: "ubuntu",
label: "Ubuntu",
classNames: "body-font-ubuntu",
},
];
}
module(
"Integration | Component | Wizard | Fields | Dropdown",
function (hooks) {
setupRenderingTest(hooks);
test("color_scheme field sets colors data on each field choice to render palettes in dropdown", async function (assert) {
const lightColors = [
{
name: "primary",
hex: "222222",
},
{
name: "secondary",
hex: "ffffff",
},
{
name: "tertiary",
hex: "0088cc",
},
{
name: "quaternary",
hex: "e45735",
},
{
name: "header_background",
hex: "ffffff",
},
{
name: "header_primary",
hex: "333333",
},
{
name: "highlight",
hex: "ffff4d",
},
{
name: "selected",
hex: "d1f0ff",
},
{
name: "hover",
hex: "f2f2f2",
},
{
name: "danger",
hex: "c80001",
},
{
name: "success",
hex: "009900",
},
{
name: "love",
hex: "fa6c8d",
},
];
const darkColors = [
{
name: "primary",
hex: "dddddd",
},
{
name: "secondary",
hex: "222222",
},
{
name: "tertiary",
hex: "099dd7",
},
{
name: "quaternary",
hex: "c14924",
},
{
name: "header_background",
hex: "111111",
},
{
name: "header_primary",
hex: "dddddd",
},
{
name: "highlight",
hex: "a87137",
},
{
name: "selected",
hex: "052e3d",
},
{
name: "hover",
hex: "313131",
},
{
name: "danger",
hex: "e45735",
},
{
name: "success",
hex: "1ca551",
},
{
name: "love",
hex: "fa6c8d",
},
];
const field = new Field({
type: "dropdown",
id: "color_scheme",
label: "Color palette",
choices: [
new Choice({
id: "light",
label: "Light",
data: { colors: lightColors },
}),
new Choice({
id: "dark",
label: "Dark",
data: { colors: darkColors },
}),
],
});
await render(<template><Dropdown @field={{field}} /></template>);
const colorPalettesSelector = selectKit(
".wizard-container__dropdown.color-palettes"
);
await colorPalettesSelector.expand();
lightColors
.reject((colorDef) => colorDef.name === "secondary")
.forEach((colorDef) => {
assert
.dom(
`.palettes .palette[style*='background-color:#${colorDef.hex}']`,
colorPalettesSelector.rowByValue("light").el()
)
.exists();
});
darkColors
.reject((colorDef) => colorDef.name === "secondary")
.forEach((colorDef) => {
assert
.dom(
`.palettes .palette[style*='background-color:#${colorDef.hex}']`,
colorPalettesSelector.rowByValue("dark").el()
)
.exists();
});
});
test("body_font sets body-font-X classNames on each field choice", async function (assert) {
const fontChoices = buildFontChoices();
const field = new Field({
type: "dropdown",
id: "body_font",
label: "Body font",
choices: fontChoices.map((choice) => new Choice(choice)),
});
await render(<template><Dropdown @field={{field}} /></template>);
const fontSelector = selectKit(
".wizard-container__dropdown.font-selector"
);
await fontSelector.expand();
fontChoices.forEach((choice) => {
assert.true(
fontSelector
.rowByValue(choice.id)
.hasClass(`body-font-${choice.id.replace("_", "-")}`),
`has body-font-${choice.id} CSS class`
);
});
});
test("heading_font sets heading-font-x classNames on each field choice", async function (assert) {
const fontChoices = buildFontChoices();
const field = new Field({
type: "dropdown",
id: "heading_font",
label: "heading font",
choices: fontChoices.map((choice) => new Choice(choice)),
});
await render(<template><Dropdown @field={{field}} /></template>);
const fontSelector = selectKit(
".wizard-container__dropdown.font-selector"
);
await fontSelector.expand();
fontChoices.forEach((choice) => {
assert.true(
fontSelector
.rowByValue(choice.id)
.hasClass(`heading-font-${choice.id.replace("_", "-")}`),
`has heading-font-${choice.id} CSS class`
);
});
});
}
);

View File

@ -0,0 +1,14 @@
import { classNames } from "@ember-decorators/component";
import ComboBoxComponent from "select-kit/components/combo-box";
import { pluginApiIdentifiers, selectKitOptions } from "./select-kit";
@classNames("font-selector")
@pluginApiIdentifiers(["font-selector"])
@selectKitOptions({
selectedNameComponent: "selected-font",
})
export default class FontSelector extends ComboBoxComponent {
modifyComponentForRow() {
return "font-selector/font-selector-row";
}
}

View File

@ -0,0 +1,5 @@
import { classNames } from "@ember-decorators/component";
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
@classNames("font-selector-row")
export default class FontSelectorRow extends SelectKitRowComponent {}

View File

@ -0,0 +1,11 @@
import { classNames } from "@ember-decorators/component";
import ComboBoxComponent from "select-kit/components/combo-box";
import { pluginApiIdentifiers } from "./select-kit";
@classNames("homepage-style-selector")
@pluginApiIdentifiers(["homepage-style-selector"])
export default class HomepageStyleSelector extends ComboBoxComponent {
modifyComponentForRow() {
return "homepage-style-selector/homepage-style-selector-row";
}
}

View File

@ -0,0 +1,6 @@
<div class="texts">
<span class="name">{{html-safe this.label}}</span>
{{#if this.item.description}}
<span class="desc">{{html-safe this.item.description}}</span>
{{/if}}
</div>

View File

@ -0,0 +1,5 @@
import { classNames } from "@ember-decorators/component";
import SelectKitRowComponent from "select-kit/components/select-kit/select-kit-row";
@classNames("homepage-style-selector-row")
export default class HomepageStyleSelectorRow extends SelectKitRowComponent {}

View File

@ -0,0 +1,10 @@
import concatClass from "discourse/helpers/concat-class";
import SelectedNameComponent from "select-kit/components/selected-name";
export default class SelectedFont extends SelectedNameComponent {
<template>
<span class={{concatClass "name" this.item.classNames}}>
{{this.label}}
</span>
</template>
}

View File

@ -1,3 +1,6 @@
$blob-bg: absolute-image-url("/branded-background.svg");
$blob-mobile-bg: absolute-image-url("/branded-background-mobile.svg");
@keyframes bump {
0% {
transform: scale(1);
@ -13,7 +16,12 @@
}
body.wizard {
background-color: var(--primary-50);
background-color: var(--secondary);
background-image: $blob-bg;
background-size: 110vw 110vh; // crops better than cover at various viewport sizes
background-repeat: no-repeat;
background-position: bottom;
color: var(--primary-very-high);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen-Sans, Ubuntu, Cantarell, Arial, sans-serif;
@ -22,12 +30,18 @@ body.wizard {
max-width: 100%;
}
@include breakpoint("mobile-extra-large") {
background: $blob-mobile-bg;
background-size: cover;
background-repeat: no-repeat;
background-position: bottom;
}
#wizard-main {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 1.5em;
background-image: absolute-image-url("/bubbles-bg.png");
height: 100%;
}
@ -137,6 +151,12 @@ body.wizard {
}
}
&__step.styling .wizard-container__field.styling-preview-field {
label {
display: none;
}
}
&__field {
margin-bottom: 1em;
}
@ -165,12 +185,18 @@ body.wizard {
}
}
&__field.dropdown-field.dropdown-homepage-style {
.wizard-container__dropdown {
height: auto;
}
}
&__step-form {
display: flex;
}
&__sidebar {
width: 170px;
width: 230px;
box-sizing: border-box;
margin-right: 1em;
@ -307,7 +333,6 @@ body.wizard {
&__step-text {
display: inline;
margin-right: 0.25em;
@media only screen and (max-width: 568px) {
display: none;
}
@ -539,6 +564,7 @@ body.wizard {
&__dropdown {
width: 100%;
height: 38px;
}
&__dropdown .select-kit-header:not(.btn) {
@ -553,6 +579,19 @@ body.wizard {
overflow: hidden;
}
&__dropdown.homepage-style-selector {
.select-kit-row {
.name {
font-weight: bold;
}
.desc {
display: block;
margin-top: 0.1em;
}
}
}
&__field.checkbox-field .wizard-container__label {
cursor: pointer;
display: inline-block;

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
class ColorScheme < ActiveRecord::Base
CUSTOM_SCHEMES = {
BUILT_IN_SCHEMES = {
Dark: {
"primary" => "dddddd",
"secondary" => "222222",
@ -286,7 +286,7 @@ class ColorScheme < ActiveRecord::Base
list = [{ id: LIGHT_THEME_ID, colors: base_with_hash }]
CUSTOM_SCHEMES.each do |k, v|
BUILT_IN_SCHEMES.each do |k, v|
colors = []
v.each { |name, color| colors << { name: name, hex: "#{color}" } }
list.push(id: k.to_s, colors: colors)
@ -385,7 +385,7 @@ class ColorScheme < ActiveRecord::Base
new_color_scheme.user_selectable = true
colors =
CUSTOM_SCHEMES[params[:base_scheme_id].to_sym]&.map do |name, hex|
BUILT_IN_SCHEMES[params[:base_scheme_id].to_sym]&.map do |name, hex|
{ name: name, hex: hex }
end if params[:base_scheme_id]
colors ||= base.colors_hashes
@ -439,7 +439,7 @@ class ColorScheme < ActiveRecord::Base
def base_colors
colors = nil
colors = CUSTOM_SCHEMES[base_scheme_id.to_sym] if base_scheme_id && base_scheme_id != "Light"
colors = BUILT_IN_SCHEMES[base_scheme_id.to_sym] if base_scheme_id && base_scheme_id != "Light"
colors || ColorScheme.base_colors
end

View File

@ -72,7 +72,7 @@ class SiteSetting < ActiveRecord::Base
end
def self.top_menu_items
top_menu.split("|").map { |menu_item| TopMenuItem.new(menu_item) }
top_menu_map.map { |menu_item| TopMenuItem.new(menu_item) }
end
def self.homepage

View File

@ -372,6 +372,10 @@ class Theme < ActiveRecord::Base
end
end
def self.find_default
find_by(id: SiteSetting.default_theme_id)
end
def self.lookup_field(theme_id, target, field, skip_transformation: false, csp_nonce: nil)
return "" if theme_id.blank?

View File

@ -7408,8 +7408,42 @@ en:
regular: "Regular User"
previews:
topic_title: "A discussion topic heading"
topic_title: "What books are you reading?"
share_button: "Share"
reply_button: "Reply"
topic_preview: "Topic preview"
homepage_preview: "Homepage preview"
homepage_preview:
nav_buttons:
all_categories: "all categories"
new: "New"
unread: "Unread"
top: "Top"
latest: "Latest"
hot: "Hot"
categories: "Categories"
topic_titles:
what_books: "What books are you reading?"
what_movies: "What movies have you seen recently?"
random_fact: "Random fact of the day"
tv_show: "Recommend a TV show"
topic_ops:
what_books: |
We all love to read, let's use this topic to share our
current or recent reads. I'm a fantasy fan and I've been
re-reading The Lord of the Rings for the 100th time.
What about you?
category_descriptions:
icebreakers: "Get to know your fellow community members with fun questions."
news: "Discuss the latest news and events."
site_feedback: "Share your thoughts on the community and suggest improvements."
category_names:
icebreakers: "Icebreakers"
news: "News"
site_feedback: "Site Feedback"
table_headers:
topic: "Topic"
replies: "Replies"
views: "Views"
activity: "Activity"

View File

@ -6264,23 +6264,17 @@ en:
label: "Homepage style"
choices:
latest:
label: "Latest Topics"
categories_only:
label: "Categories Only"
categories_with_featured_topics:
label: "Categories with Featured Topics"
label: "Latest"
description: "Displays the most recently active topics in all categories, helping members stay up-to-date with discussions they care about"
hot:
label: "Hot"
description: "Surfaces trending topics by blending recent and overall popularity, showcases what members are talking about in your community right now"
categories_and_latest_topics:
label: "Categories and Latest Topics"
categories_and_latest_topics_created_date:
label: "Categories and Latest Topics (sort by topic created date)"
categories_and_top_topics:
label: "Categories and Top Topics"
label: "Categories with latest topics"
description: "Combines the recently active topics across all categories with a list of categories, their description, and total topics"
categories_boxes:
label: "Categories Boxes"
categories_boxes_with_topics:
label: "Categories Boxes with Topics"
subcategories_with_featured_topics:
label: "Subcategories with Featured Topics"
label: "Category boxes"
description: "Displays the categories and their description in a grid, ideal for members to see an overview of the sub-communities of your site"
branding:
title: "Site logo"

View File

@ -86,7 +86,7 @@ module Stylesheet
.body-font-#{font[:key].tr("_", "-")} {
font-family: #{font[:stack]};
}
.heading-font-#{font[:key].tr("_", "-")} h2 {
.heading-font-#{font[:key].tr("_", "-")} {
font-family: #{font[:stack]};
}
CSS
@ -222,7 +222,7 @@ module Stylesheet
)
contents << <<~CSS
@font-face {
font-family: #{font[:name]};
font-family: '#{font[:name]}';
src: #{src};
font-weight: #{variant[:weight]};
}

View File

@ -9,6 +9,20 @@ class Wizard
def build
return @wizard unless SiteSetting.wizard_enabled? && @wizard.user.try(:staff?)
append_introduction_step
append_privacy_step
append_styling_step
append_ready_step
append_branding_step
append_corporate_step
DiscourseEvent.trigger(:build_wizard, @wizard)
@wizard
end
protected
def append_introduction_step
@wizard.append_step("introduction") do |step|
step.emoji = "wave"
step.description_vars = { base_path: Discourse.base_path }
@ -56,7 +70,9 @@ class Wizard
end
end
end
end
def append_privacy_step
@wizard.append_step("privacy") do |step|
step.emoji = "hugs"
@ -93,12 +109,16 @@ class Wizard
updater.update_setting(:must_approve_users, updater.fields[:must_approve_users] == "yes")
end
end
end
def append_ready_step
@wizard.append_step("ready") do |step|
# no form on this page, just info.
step.emoji = "rocket"
end
end
def append_branding_step
@wizard.append_step("branding") do |step|
step.emoji = "framed_picture"
step.add_field(id: "logo", type: "image", value: SiteSetting.site_logo_url)
@ -112,10 +132,12 @@ class Wizard
end
end
end
end
def append_styling_step
@wizard.append_step("styling") do |step|
step.emoji = "art"
default_theme = Theme.find_by(id: SiteSetting.default_theme_id)
default_theme = Theme.find_default
default_theme_override = SiteSetting.exists?(name: "default_theme_id")
base_scheme = default_theme&.color_scheme&.base_scheme_id
@ -159,17 +181,20 @@ class Wizard
show_in_sidebar: true,
)
DiscourseFonts.fonts.each do |font|
body_font.add_choice(font[:key], label: font[:name])
heading_font.add_choice(font[:key], label: font[:name])
end
DiscourseFonts
.fonts
.sort_by { |f| f[:name] }
.each do |font|
body_font.add_choice(font[:key], label: font[:name])
heading_font.add_choice(font[:key], label: font[:name])
end
current =
(
if SiteSetting.top_menu.starts_with?("categories")
if SiteSetting.homepage == "categories"
SiteSetting.desktop_category_page_style
else
"latest"
SiteSetting.homepage
end
)
style =
@ -181,7 +206,11 @@ class Wizard
show_in_sidebar: true,
)
style.add_choice("latest")
CategoryPageStyle.values.each { |page| style.add_choice(page[:value]) }
style.add_choice("hot")
# Subset of CategoryPageStyle, we don't want to show all the options here.
style.add_choice("categories_and_latest_topics")
style.add_choice("categories_boxes")
step.add_field(id: "styling_preview", type: "styling-preview")
@ -189,10 +218,11 @@ class Wizard
updater.update_setting(:base_font, updater.fields[:body_font])
updater.update_setting(:heading_font, updater.fields[:heading_font])
top_menu = SiteSetting.top_menu.split("|")
if updater.fields[:homepage_style] == "latest" && top_menu[0] != "latest"
top_menu.delete("latest")
top_menu.insert(0, "latest")
top_menu = SiteSetting.top_menu_map
if %w[latest hot].include?(updater.fields[:homepage_style]) &&
top_menu.first != updater.fields[:homepage_style]
top_menu.delete(updater.fields[:homepage_style])
top_menu.insert(0, updater.fields[:homepage_style])
elsif updater.fields[:homepage_style] != "latest"
top_menu.delete("categories")
top_menu.insert(0, "categories")
@ -228,7 +258,9 @@ class Wizard
updater.refresh_required = true
end
end
end
def append_corporate_step
@wizard.append_step("corporate") do |step|
step.emoji = "briefcase"
step.description_vars = { base_path: Discourse.base_path }
@ -256,13 +288,8 @@ class Wizard
end
end
end
DiscourseEvent.trigger(:build_wizard, @wizard)
@wizard
end
protected
def replace_setting_value(updater, raw, field_name)
old_value = SiteSetting.get(field_name)
old_value = field_name if old_value.blank?

View File

@ -8,6 +8,8 @@ Fabricator(:topic) do
end
end
Fabricator(:topic_with_op, from: :topic) { after_create { |topic| Fabricate(:post, topic: topic) } }
Fabricator(:deleted_topic, from: :topic) { deleted_at { 1.minute.ago } }
Fabricator(:closed_topic, from: :topic) { closed true }

View File

@ -92,12 +92,12 @@ RSpec.describe Wizard::Builder do
describe "styling" do
let(:styling_step) { wizard.steps.find { |s| s.id == "styling" } }
let(:font_field) { styling_step.fields[1] }
let(:font_field) { styling_step.fields.find { |f| f.id == "body_font" } }
fab!(:theme)
let(:colors_field) { styling_step.fields.first }
it "has the full list of available fonts" do
expect(font_field.choices.size).to eq(DiscourseFonts.fonts.size)
it "has the full list of available fonts in alphabetical order" do
expect(font_field.choices.map(&:label)).to eq(DiscourseFonts.fonts.map { |f| f[:name] }.sort)
end
context "with colors" do

View File

@ -146,7 +146,7 @@ RSpec.describe ColorScheme do
it "falls back to default scheme if base scheme does not have color" do
custom_scheme_id = "BaseSchemeWithNoHighlightColor"
ColorScheme::CUSTOM_SCHEMES[custom_scheme_id.to_sym] = { "secondary" => "123123" }
ColorScheme::BUILT_IN_SCHEMES[custom_scheme_id.to_sym] = { "secondary" => "123123" }
color_scheme = ColorScheme.new(base_scheme_id: custom_scheme_id)
color_scheme.color_scheme_colors << ColorSchemeColor.new(name: "primary", hex: "121212")
@ -156,7 +156,7 @@ RSpec.describe ColorScheme do
expect(resolved["secondary"]).to eq("123123") # From custom scheme
expect(resolved["tertiary"]).to eq("0088cc") # From `foundation/colors.scss`
ensure
ColorScheme::CUSTOM_SCHEMES.delete(custom_scheme_id.to_sym)
ColorScheme::BUILT_IN_SCHEMES.delete(custom_scheme_id.to_sym)
end
it "calculates 'hover' and 'selected' from existing db colors in dark mode" do

View File

@ -3,17 +3,148 @@
module PageObjects
module Pages
class Wizard < PageObjects::Pages::Base
attr_reader :introduction_step,
:privacy_step,
:ready_step,
:branding_step,
:styling_step,
:corporate_step
def initialize
@introduction_step = PageObjects::Pages::Wizard::IntroductionStep.new(self)
@privacy_step = PageObjects::Pages::Wizard::PrivacyStep.new(self)
@ready_step = PageObjects::Pages::Wizard::ReadyStep.new(self)
@branding_step = PageObjects::Pages::Wizard::BrandingStep.new(self)
@styling_step = PageObjects::Pages::Wizard::StylingStep.new(self)
@corporate_step = PageObjects::Pages::Wizard::CorporateStep.new(self)
end
def go_to_step(step_id)
visit("/wizard/steps/#{step_id}")
end
def on_step?(step_id)
has_css?(".wizard-container__step.#{step_id}")
end
def click_jump_in
find(".jump-in").click
find(".wizard-container__button.jump-in").click
end
def click_configure_more
find(".wizard-container__button.configure-more").click
end
def go_to_next_step
find(".wizard-container__button.next").click
end
def select_access_option(label)
find(".wizard-container__radio-choice", text: label).click
def find_field(field_type, field_id)
find(".wizard-container__field.#{field_type}-field.#{field_type}-#{field_id}")
end
def fill_field(field_type, field_id, value)
find_field(field_type, field_id).fill_in(with: value)
end
def has_field_with_value?(field_type, field_id, value)
find_field(field_type, field_id).find("input").value == value
end
end
end
end
class PageObjects::Pages::Wizard::StepBase < PageObjects::Pages::Base
attr_reader :wizard
def initialize(wizard)
@wizard = wizard
end
end
class PageObjects::Pages::Wizard::IntroductionStep < PageObjects::Pages::Wizard::StepBase
end
class PageObjects::Pages::Wizard::PrivacyStep < PageObjects::Pages::Wizard::StepBase
def choice_selector(choice_id)
".wizard-container__radio-choice[data-choice-id='#{choice_id}']"
end
def select_access_option(section, choice_id)
wizard.find_field("radio", section).find(choice_selector(choice_id)).click
end
def has_selected_choice?(section, choice_id)
wizard.find_field("radio", section).has_css?(choice_selector(choice_id) + ".--selected")
end
end
class PageObjects::Pages::Wizard::ReadyStep < PageObjects::Pages::Wizard::StepBase
end
class PageObjects::Pages::Wizard::BrandingStep < PageObjects::Pages::Wizard::StepBase
def click_upload_button(field_id)
wizard.find_field("image", field_id).find(".wizard-container__button-upload").click
end
def has_upload?(field_id)
wizard.find_field("image", field_id).has_css?(".wizard-container__button-upload.has-upload")
end
end
class PageObjects::Pages::Wizard::StylingStep < PageObjects::Pages::Wizard::StepBase
def select_color_palette_option(palette)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-color-scheme .wizard-container__dropdown")
select_kit.expand
select_kit.select_row_by_value(palette)
end
def select_body_font_option(font)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-body-font .wizard-container__dropdown")
select_kit.expand
select_kit.select_row_by_value(font)
end
def select_heading_font_option(font)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-heading-font .wizard-container__dropdown")
select_kit.expand
select_kit.select_row_by_value(font)
end
def select_homepage_style_option(homepage)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-homepage-style .wizard-container__dropdown")
select_kit.expand
select_kit.select_row_by_value(homepage)
end
def has_selected_color_palette?(palette)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-color-scheme .wizard-container__dropdown")
select_kit.has_selected_value?(palette)
end
def has_selected_body_font?(font)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-body-font .wizard-container__dropdown")
select_kit.has_selected_value?(font)
end
def has_selected_heading_font?(font)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-heading-font .wizard-container__dropdown")
select_kit.has_selected_value?(font)
end
def has_selected_homepage_style?(hompage)
select_kit =
PageObjects::Components::SelectKit.new(".dropdown-homepage-style .wizard-container__dropdown")
select_kit.has_selected_value?(hompage)
end
end
class PageObjects::Pages::Wizard::CorporateStep < PageObjects::Pages::Wizard::StepBase
end

View File

@ -2,55 +2,153 @@
describe "Wizard", type: :system do
fab!(:admin)
fab!(:topic) { Fabricate(:topic, title: "admin guide with 15 chars") }
fab!(:post) { Fabricate(:post, topic: topic) }
let(:wizard_page) { PageObjects::Pages::Wizard.new }
before { sign_in(admin) }
it "lets user configure member access" do
visit("/wizard/steps/privacy")
expect(page).to have_css(
".wizard-container__radio-choice.--selected",
text: I18n.t("wizard.step.privacy.fields.login_required.choices.public.label"),
)
wizard_page.select_access_option("Private")
expect(page).to have_css(
".wizard-container__radio-choice.--selected",
text: I18n.t("wizard.step.privacy.fields.login_required.choices.private.label"),
)
it "successfully goes through every step of the wizard" do
visit("/wizard")
expect(wizard_page).to be_on_step("introduction")
wizard_page.fill_field("text", "title", "My Test Site")
wizard_page.go_to_next_step
expect(page).to have_current_path("/wizard/steps/ready")
expect(SiteSetting.login_required).to eq(true)
visit("/wizard/steps/privacy")
expect(page).to have_css(
".wizard-container__radio-choice.--selected",
text: I18n.t("wizard.step.privacy.fields.login_required.choices.private.label"),
)
end
it "redirects to latest when wizard is completed" do
visit("/wizard/steps/ready")
expect(wizard_page).to be_on_step("privacy")
wizard_page.go_to_next_step
expect(wizard_page).to be_on_step("styling")
wizard_page.go_to_next_step
expect(wizard_page).to be_on_step("ready")
wizard_page.click_configure_more
expect(wizard_page).to be_on_step("branding")
wizard_page.go_to_next_step
expect(wizard_page).to be_on_step("corporate")
wizard_page.click_jump_in
expect(page).to have_current_path("/latest")
end
it "redirects to admin guide when wizard is completed and bootstrap mode is enabled" do
SiteSetting.bootstrap_mode_enabled = true
SiteSetting.admin_quick_start_topic_id = topic.id
describe "Wizard Step: Privacy" do
it "lets user configure member access" do
wizard_page.go_to_step("privacy")
expect(SiteSetting.login_required).to eq(false)
expect(SiteSetting.invite_only).to eq(false)
expect(SiteSetting.must_approve_users).to eq(false)
visit("/wizard/steps/ready")
wizard_page.click_jump_in
expect(wizard_page.privacy_step).to have_selected_choice("login-required", "public")
expect(wizard_page.privacy_step).to have_selected_choice("invite-only", "sign_up")
expect(wizard_page.privacy_step).to have_selected_choice("must-approve-users", "no")
expect(page).to have_current_path("/t/admin-guide-with-15-chars/#{topic.id}")
wizard_page.privacy_step.select_access_option("login-required", "private")
wizard_page.privacy_step.select_access_option("invite-only", "invite_only")
wizard_page.privacy_step.select_access_option("must-approve-users", "yes")
wizard_page.go_to_next_step
expect(wizard_page).to be_on_step("styling")
expect(SiteSetting.login_required).to eq(true)
expect(SiteSetting.invite_only).to eq(true)
expect(SiteSetting.must_approve_users).to eq(true)
wizard_page.go_to_step("privacy")
expect(wizard_page.privacy_step).to have_selected_choice("login-required", "private")
expect(wizard_page.privacy_step).to have_selected_choice("invite-only", "invite_only")
expect(wizard_page.privacy_step).to have_selected_choice("must-approve-users", "yes")
end
end
describe "Wizard Step: Branding" do
let(:file_path_1) { file_from_fixtures("logo.png", "images").path }
let(:file_path_2) { file_from_fixtures("logo.jpg", "images").path }
it "lets user configure logos" do
wizard_page.go_to_step("branding")
expect(wizard_page).to be_on_step("branding")
attach_file(file_path_1) { wizard_page.branding_step.click_upload_button("logo") }
expect(wizard_page.branding_step).to have_upload("logo")
attach_file(file_path_2) { wizard_page.branding_step.click_upload_button("logo-small") }
expect(wizard_page.branding_step).to have_upload("logo-small")
wizard_page.go_to_next_step
expect(wizard_page).to be_on_step("corporate")
expect(SiteSetting.logo).to eq(Upload.find_by(original_filename: File.basename(file_path_1)))
expect(SiteSetting.logo_small).to eq(
Upload.find_by(original_filename: File.basename(file_path_2)),
)
end
end
describe "Wizard Step: Styling" do
it "lets user configure styling including fonts and colors" do
wizard_page.go_to_step("styling")
expect(wizard_page).to be_on_step("styling")
wizard_page.styling_step.select_color_palette_option("Dark")
wizard_page.styling_step.select_body_font_option("lato")
wizard_page.styling_step.select_heading_font_option("merriweather")
wizard_page.styling_step.select_homepage_style_option("hot")
wizard_page.go_to_next_step
expect(wizard_page).to be_on_step("ready")
expect(Theme.find_default.color_scheme_id).to eq(
ColorScheme.find_by(base_scheme_id: "Dark", via_wizard: true).id,
)
expect(SiteSetting.base_font).to eq("lato")
expect(SiteSetting.heading_font).to eq("merriweather")
expect(SiteSetting.homepage).to eq("hot")
wizard_page.go_to_step("styling")
expect(wizard_page.styling_step).to have_selected_color_palette("Dark")
expect(wizard_page.styling_step).to have_selected_body_font("lato")
expect(wizard_page.styling_step).to have_selected_heading_font("merriweather")
expect(wizard_page.styling_step).to have_selected_homepage_style("hot")
end
end
describe "Wizard Step: Ready" do
it "redirects to latest" do
wizard_page.go_to_step("ready")
wizard_page.click_jump_in
expect(page).to have_current_path("/latest")
end
it "redirects to admin guide when bootstrap mode is enabled" do
topic = Fabricate(:topic_with_op, title: "Admin Getting Started Guide")
SiteSetting.bootstrap_mode_enabled = true
SiteSetting.admin_quick_start_topic_id = topic.id
wizard_page.go_to_step("ready")
wizard_page.click_jump_in
expect(page).to have_current_path(topic.url)
end
end
describe "Wizard Step: Corporate" do
it "lets user configure corporate including governing law and city for disputes" do
wizard_page.go_to_step("corporate")
expect(wizard_page).to be_on_step("corporate")
wizard_page.fill_field("text", "company-name", "ACME")
wizard_page.fill_field("text", "governing-law", "California")
wizard_page.fill_field("text", "contact-url", "https://ac.me")
wizard_page.fill_field("text", "city-for-disputes", "San Francisco")
wizard_page.fill_field("text", "contact-email", "coyote@ac.me")
wizard_page.click_jump_in
expect(page).to have_current_path("/latest")
expect(SiteSetting.company_name).to eq("ACME")
expect(SiteSetting.governing_law).to eq("California")
expect(SiteSetting.city_for_disputes).to eq("San Francisco")
expect(SiteSetting.contact_url).to eq("https://ac.me")
expect(SiteSetting.contact_email).to eq("coyote@ac.me")
wizard_page.go_to_step("corporate")
expect(wizard_page).to have_field_with_value("text", "company-name", "ACME")
expect(wizard_page).to have_field_with_value("text", "governing-law", "California")
expect(wizard_page).to have_field_with_value("text", "contact-url", "https://ac.me")
expect(wizard_page).to have_field_with_value("text", "city-for-disputes", "San Francisco")
expect(wizard_page).to have_field_with_value("text", "contact-email", "coyote@ac.me")
end
end
end