FEATURE: onboarding banner for admins (#37583)

We planned to add a new banner for newly created communities to help
guide site owners through initial setup steps.

At the moment, it has 3 items in the checklist:
- Invite collaborators -> Opens invite modal
- Start posting -> Opens composer with a seed icebreaker topic
- Spread the word -> Copies forum link to clipboard

You can see it working in this video:


https://github.com/user-attachments/assets/ed693321-e416-43f9-8182-0243af193688


Current visuals:

<img width="1723" height="393" alt="image"
src="https://github.com/user-attachments/assets/3021c0b1-30e8-4ba6-aa43-aebf71061a8a"
/>



TODO:
- [x] Tests
- [x] Work on CSS

Maybe todos:
- [x] Exclude routes where the banner shouldn't appear
- [x] Add metadata in ice breaker post to  identify it
as an ice breaker topic?
- [x] Check if there is a way to determine if the user is the _first_
admin user?
- [x] Should we have a happy indicator when the user finishes all
tasks(maybe a toast?)

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
This commit is contained in:
Gabriel Grubba
2026-02-10 14:53:44 -03:00
committed by GitHub
co-authored by Penar Musaraj
parent 2a1dae078b
commit 07a747eecb
11 changed files with 559 additions and 1 deletions
@@ -45,6 +45,7 @@
@import "post-translations";
@import "relative-time-picker";
@import "add-pm-participants";
@import "admin-onboarding-banner";
@import "download-calendar";
@import "sidebar/edit-navigation-menu/categories-modal";
@import "sidebar/edit-navigation-menu/modal";
@@ -0,0 +1,78 @@
@use "lib/viewport";
@import "common/foundation/mixins";
.admin-onboarding-banner {
@include viewport.until(sm) {
display: none;
}
color: dark-light-choose(var(--secondary), var(--primary));
&__wrap {
box-sizing: border-box;
position: relative;
padding: var(--space-6) var(--space-3);
border-radius: var(--d-border-radius-large);
background: var(--accent-color, var(--tertiary-high));
@include viewport.until(md) {
padding: var(--space-4) 8px var(--space-5);
margin-top: var(--space-2);
}
@include viewport.until(sm) {
display: block;
padding: var(--space-2);
margin-top: 0;
}
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-4);
margin-bottom: var(--space);
}
&__content {
text-align: center;
padding: 0 var(--space-4);
@include viewport.until(md) {
margin-top: var(--space-3);
}
}
&__steps {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-6);
.onboarding-step {
display: flex;
justify-content: center;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: var(--space-2);
.btn-default {
border-radius: var(--d-border-radius);
svg {
color: dark-light-choose(
var(--secondary-medium),
var(--primary-medium)
);
}
}
&__checkbox {
gap: var(--space-2);
display: flex;
align-items: center;
}
}
}
}
@@ -32,7 +32,16 @@ class Admin::SiteSettingsController < Admin::AdminController
settings = [{ setting_name: id, value: params[id], backfill: }]
end
SiteSetting::Update.call(params: { settings: }, guardian:) do
SiteSetting::Update.call(
guardian:,
params: {
settings:,
},
options: {
# TODO: remove once the site setting is no longer hidden
allow_changing_hidden: %i[enable_site_owner_onboarding],
},
) do
on_success { head :no_content }
on_exceptions { |e| raise Discourse::InvalidParameters, e }
on_failed_policy(:settings_are_not_deprecated) do |policy|
+33
View File
@@ -454,6 +454,39 @@ en:
pill: "Suggested"
pm_title: "Suggested Messages"
admin_onboarding_banner:
launch_in_easy_steps: "Launch in %{step_count} easy steps"
skipped: "The admin onboarding checklist has now been skipped. If you would like to re-enable it, look for `enable site owner onboarding` in the site settings."
congrats_onboarding_complete: "Congratulations on completing the onboarding process!"
invite_collaborators:
title: "Invite Collaborators"
description: "Early members help bring your community to life"
action: "Create invite"
start_posting:
title: "Start Posting"
description: "Give people something to talk about together"
action: "Icebreaker ideas"
icebreakers:
fun_facts:
title: "What are some fun facts about you?"
body: "Hi everyone! I'm excited to start this new community. To kick things off, let's share a fun fact about ourselves. I'll go first: I love hiking and exploring new trails! What's something interesting about you?"
coolest_thing_you_have_seen_today:
title: "What's the coolest thing you've seen today?"
body: "What's the coolest thing you've seen or experienced today? It could be anything from a beautiful sunset to a random act of kindness. I'll start: I saw a dog helping an elderly person cross the street!"
introduce_yourself:
title: "Introduce yourself"
body: "Welcome to the community! Let's get to know each other. Please introduce yourself and share a little bit about your interests. I'll go first: I'm passionate about technology and love teaching others!"
what_is_your_favorite_food:
title: "What is your favorite food?"
body: "Share your favorite food with the community! I'll start: I love pizza, especially with extra cheese and pepperoni."
spread_the_word:
title: "Spread the Word!"
description: "Share a link with everyone in your network"
action: "Copy link"
copied_to_clipboard: "Link copied to clipboard!"
about:
edit: "Edit this page"
simple_title: "About"
+1
View File
@@ -2713,6 +2713,7 @@ en:
suggest_weekends_in_date_pickers: "Include weekends (Saturday and Sunday) in date picker suggestions (disable this if you use Discourse only on weekdays, Monday through Friday)."
show_bottom_topic_map: "Shows the topic map at the bottom of the topic when it has 10 replies or more."
show_topic_map_in_topics_without_replies: "Shows the topic map even if the topic has no replies."
enable_site_owner_onboarding: "Shows a panel on the homepage to new admins to help them get started. Re-enable this setting to see the onboarding steps again."
enable_welcome_banner: "Display a banner on your main topic list pages to welcome members and allow them to search site content"
welcome_banner_image: "Set an image to display in the background of the welcome banner."
welcome_banner_location: "Determines where on the page the welcome banner will appear."
+6
View File
@@ -3790,6 +3790,12 @@ uncategorized:
default: true
area: "posts_and_topics"
enable_site_owner_onboarding:
client: true
default: false
hidden: true
area: "site_admin"
enable_welcome_banner:
client: true
default: true
@@ -0,0 +1,209 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import { TrackedArray } from "@ember-compat/tracked-built-ins";
import SiteSetting from "discourse/admin/models/site-setting";
import OnboardingStep from "discourse/components/admin-onboarding/step";
import DButton from "discourse/components/d-button";
import CreateInvite from "discourse/components/modal/create-invite";
import { getAbsoluteURL } from "discourse/lib/get-url";
import { clipboardCopy, defaultHomepage } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
const STEPS = [
class StartPosting extends OnboardingStep {
static name = "start_posting";
@service composer;
@service appEvents;
icon = "comments";
icebreaker_topics = [
"fun_facts",
"coolest_thing_you_have_seen_today",
"introduce_yourself",
"what_is_your_favorite_food",
];
constructor() {
super(...arguments);
this.appEvents.on("topic:created", this, this.checkIfPosted);
}
willDestroy() {
super.willDestroy(...arguments);
this.appEvents.off("topic:created", this, this.checkIfPosted);
}
checkIfPosted() {
this.markAsCompleted();
}
@action
async performAction() {
const randomTopic =
this.icebreaker_topics[
Math.floor(Math.random() * this.icebreaker_topics.length)
];
this.composer.openNewTopic({
title: i18n(
`admin_onboarding_banner.start_posting.icebreakers.${randomTopic}.title`
),
body: i18n(
`admin_onboarding_banner.start_posting.icebreakers.${randomTopic}.body`
),
});
}
},
class InviteCollaborators extends OnboardingStep {
static name = "invite_collaborators";
@service modal;
@service appEvents;
step = this.name;
icon = "paper-plane";
constructor() {
super(...arguments);
this.appEvents.on("create-invite:saved", this, this.markAsCompleted);
}
willDestroy() {
super.willDestroy(...arguments);
this.appEvents.off("create-invite:saved", this, this.markAsCompleted);
}
@action
performAction() {
this.modal.show(CreateInvite, {
model: { invites: new TrackedArray() },
});
}
},
class SpreadTheWord extends OnboardingStep {
static name = "spread_the_word";
@service toasts;
@tracked icon = "copy";
@action
performAction() {
clipboardCopy(getAbsoluteURL("/"));
this.toasts.success({
data: {
message: i18n(
"admin_onboarding_banner.spread_the_word.copied_to_clipboard"
),
},
});
this.markAsCompleted();
}
},
];
export default class AdminOnboardingBanner extends Component {
@service siteSettings;
@service currentUser;
@service appEvents;
@service keyValueStore;
@service router;
@service toasts;
constructor() {
super(...arguments);
this.appEvents.on(
"onboarding-step:completed",
this,
this.checkIfOnboardingIsComplete
);
}
willDestroy() {
super.willDestroy(...arguments);
this.appEvents.off(
"onboarding-step:completed",
this,
this.checkIfOnboardingIsComplete
);
}
get shouldDisplay() {
if (!this.currentUser) {
return false;
}
if (!this.siteSettings.enable_site_owner_onboarding) {
return false;
}
if (!this.currentUser.admin) {
return false;
}
const { currentRouteName } = this.router;
return currentRouteName === `discovery.${defaultHomepage()}`;
}
checkIfOnboardingIsComplete() {
const allStepsAreDone = STEPS.every(
(Step) => !!this.keyValueStore.get(`onboarding_step_${Step.name}`)
);
if (allStepsAreDone) {
this.endOnboarding({ skipped: false });
}
}
@action
async endOnboarding({ skipped = true } = {}) {
await SiteSetting.update("enable_site_owner_onboarding", false);
STEPS.forEach((Step) => {
this.keyValueStore.remove(`onboarding_step_${Step.name}`);
});
const label = skipped
? "admin_onboarding_banner.skipped"
: "admin_onboarding_banner.congrats_onboarding_complete";
this.toasts.success({
data: {
message: i18n(label),
},
});
}
<template>
{{#if this.shouldDisplay}}
<div class="admin-onboarding-banner">
<div class="admin-onboarding-banner__wrap">
<div class="admin-onboarding-banner__header">
<h2>
{{i18n
"admin_onboarding_banner.launch_in_easy_steps"
(hash step_count=STEPS.length)
}}
</h2>
<DButton
@action={{this.endOnboarding}}
@icon="xmark"
class="btn no-text btn-transparent btn-close"
/>
</div>
<div class="admin-onboarding-banner__content">
<div class="admin-onboarding-banner__steps">
{{#each STEPS as |Step|}}
<Step />
{{/each}}
</div>
</div>
</div>
</div>
{{/if}}
</template>
}
@@ -0,0 +1,71 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { concat } from "@ember/helper";
import { action } from "@ember/object";
import { service } from "@ember/service";
import DButton from "discourse/components/d-button";
import icon from "discourse/helpers/d-icon";
import { i18n } from "discourse-i18n";
export default class OnboardingStep extends Component {
static name() {
throw new Error("Name is required for OnboardingStep");
}
@service keyValueStore;
@service appEvents;
@tracked
completed = this.keyValueStore.get(`onboarding_step_${this.name}`) || false;
i18nKey = "admin_onboarding_banner.";
get name() {
return this.constructor.name;
}
get icon() {
throw new Error("Icon is required for OnboardingStep");
}
@action
performAction() {
throw new Error("performAction is required for OnboardingStep");
}
markAsCompleted() {
this.keyValueStore.set({
key: `onboarding_step_${this.name}`,
value: true,
});
this.completed = true;
this.appEvents.trigger(`onboarding-step:completed`, this.name);
}
<template>
<div class="onboarding-step" id={{this.name}}>
<div class="onboarding-step__checkbox">
{{~icon
(if this.completed "square-check" "far-square")
class=(if this.completed "checked")
}}
<span>{{i18n (concat this.i18nKey this.name ".title")}}</span>
</div>
<div class="onboarding-step__description">
<span>
{{i18n (concat this.i18nKey this.name ".description")}}
</span>
</div>
<div class="onboarding-step__action">
<DButton
@icon={{this.icon}}
@label={{concat this.i18nKey this.name ".action"}}
@action={{this.performAction}}
class="btn btn-default"
/>
</div>
</div>
</template>
}
@@ -27,6 +27,7 @@ export default class CreateInvite extends Component {
@service currentUser;
@service siteSettings;
@service site;
@service appEvents;
@tracked saving = false;
@tracked displayAdvancedOptions = false;
@@ -147,6 +148,7 @@ export default class CreateInvite extends Component {
}
this.flashClass = "success";
}
this.appEvents.trigger("create-invite:saved", this.invite);
} catch (error) {
this.flashText = sanitize(extractError(error));
this.flashClass = "error";
@@ -1,6 +1,7 @@
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import A11yLiveRegions from "discourse/components/a11y/live-regions";
import A11ySkipLinks from "discourse/components/a11y/skip-links";
import AdminOnboardingBanner from "discourse/components/admin-onboarding/banner";
import CardContainer from "discourse/components/card-container";
import ComposerContainer from "discourse/components/composer-container";
import CustomHtml from "discourse/components/custom-html";
@@ -98,6 +99,10 @@ export default <template>
<div id="main-outlet">
<PluginOutlet @name="above-main-container" @connectorTagName="div" />
{{#if @controller.siteSettings.enable_site_owner_onboarding}}
<AdminOnboardingBanner />
{{/if}}
{{#if
(eq
@controller.siteSettings.welcome_banner_location
@@ -0,0 +1,143 @@
import { click, settled, visit } from "@ember/test-helpers";
import { test } from "qunit";
import sinon from "sinon";
import { AUTO_GROUPS } from "discourse/lib/constants";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
acceptance("Admin - Onboarding Banner", function (needs) {
needs.user({
admin: true,
groups: [AUTO_GROUPS.admins],
});
needs.settings({
enable_site_owner_onboarding: true,
general_category_id: 1,
default_composer_category: 1,
});
needs.pretender((server, helper) => {
server.put("/admin/site_settings/enable_site_owner_onboarding", () => {
return helper.response(200, {
success: "OK",
});
});
server.post("/invites", () => {
return helper.response(200, {
success: "OK",
});
});
});
needs.hooks.beforeEach(() => {
const mockClipboard = {
writeText: sinon.stub().resolves(true),
write: sinon.stub().resolves(true),
};
sinon.stub(window.navigator, "clipboard").get(() => mockClipboard);
});
const withStep = (id, assert) => {
return {
checkbox() {
return assert.dom(`div#${id} .onboarding-step__checkbox > svg`);
},
clickAction() {
return click(`div#${id} .onboarding-step__action .btn`);
},
isChecked() {
return this.checkbox().hasClass("checked", `${id} step is completed`);
},
isNotChecked() {
return this.checkbox().doesNotHaveClass(
"checked",
`${id} step is not completed`
);
},
};
};
test("it shows onboarding banner", async function (assert) {
await visit("/");
assert.dom(".admin-onboarding-banner").exists("shows onboarding banner");
});
test("it can end onboarding prematurely", async function (assert) {
await visit("/");
assert.dom(".admin-onboarding-banner").exists();
await click(".admin-onboarding-banner .btn-close");
this.siteSettings.enable_site_owner_onboarding = false;
await settled();
assert.dom(".admin-onboarding-banner").doesNotExist();
});
test("it can complete `start_posting` step", async function (assert) {
const step = withStep("start_posting", assert);
await visit("/");
step.isNotChecked();
await step.clickAction();
await click(".create");
await visit("/");
step.isChecked();
});
test("it can complete `invite_collaborators` step", async function (assert) {
const step = withStep("invite_collaborators", assert);
await visit("/");
step.isNotChecked();
await step.clickAction();
await click(".d-modal__footer .btn-primary");
await click(".modal-close");
step.isChecked();
});
test("it can complete `spread_the_word` step", async function (assert) {
const step = withStep("spread_the_word", assert);
await visit("/");
step.isNotChecked();
await step.clickAction();
step.isChecked();
});
});
acceptance("Admin - Onboarding Banner - non admin user", function (needs) {
needs.user({ admin: false });
needs.settings({
enable_site_owner_onboarding: true,
});
test("it does not show onboarding banner for non admin user", async function (assert) {
await visit("/");
assert
.dom(".admin-onboarding-banner")
.doesNotExist("does not show onboarding banner");
});
});
acceptance("Admin - Onboarding Banner - setting disabled", function (needs) {
needs.user({
admin: true,
groups: [AUTO_GROUPS.admins],
});
needs.settings({
enable_site_owner_onboarding: false,
});
test("it does not show onboarding banner when setting is disabled", async function (assert) {
await visit("/");
assert
.dom(".admin-onboarding-banner")
.doesNotExist("does not show onboarding banner");
});
});