2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2025-10-03 17:21:20 +08:00

FEATURE: Add welcome banner to core (#31516)

This is a stripped-back version of the Search Banner
component https://meta.discourse.org/t/search-banner/122939,
which will be renamed to Advanced Search Banner,
see https://github.com/discourse/discourse-search-banner/pull/84.

This welcome banner interacts with the header search.
When `search_experience` is set to `search_field`, we only
show the header search after the welcome banner scrolls
out of view, and vice-versa.

Only new sites will get this feature turned on by default,
existing sites have a migration to disable it.

---------

Co-authored-by: Joffrey JAFFEUX <j.jaffeux@gmail.com>
Co-authored-by: Jordan Vidrine <jordan@jordanvidrine.com>
This commit is contained in:
Martin Brennan 2025-03-17 12:18:08 +10:00 committed by GitHub
parent c5e95b419b
commit 64f1b97e0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 421 additions and 13 deletions

View file

@ -29,7 +29,7 @@ export default class HeaderSearch extends Component {


<template> <template>
{{#if this.shouldDisplay}} {{#if this.shouldDisplay}}
{{bodyClass "header-search--visible"}} {{bodyClass "header-search--enabled"}}
<div <div
class="floating-search-input-wrapper" class="floating-search-input-wrapper"
{{this.handleKeyboardShortcut}} {{this.handleKeyboardShortcut}}
@ -46,7 +46,7 @@ export default class HeaderSearch extends Component {
@href={{this.advancedSearchButtonHref}} @href={{this.advancedSearchButtonHref}}
/> />


<SearchMenu /> <SearchMenu @location="header" />
</div> </div>
</div> </div>
</div> </div>

View file

@ -21,6 +21,7 @@ export default class SearchMenuPanel extends Component {
@onClose={{@closeSearchMenu}} @onClose={{@closeSearchMenu}}
@inlineResults={{true}} @inlineResults={{true}}
@autofocusInput={{true}} @autofocusInput={{true}}
@location="header"
/> />
</MenuPanel> </MenuPanel>
</template> </template>

View file

@ -1,6 +1,6 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { hash } from "@ember/helper"; import { concat, hash } from "@ember/helper";
import { on } from "@ember/modifier"; import { on } from "@ember/modifier";
import { action } from "@ember/object"; import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import didInsert from "@ember/render-modifiers/modifiers/did-insert";
@ -14,6 +14,7 @@ import AdvancedButton from "discourse/components/search-menu/advanced-button";
import ClearButton from "discourse/components/search-menu/clear-button"; import ClearButton from "discourse/components/search-menu/clear-button";
import Results from "discourse/components/search-menu/results"; import Results from "discourse/components/search-menu/results";
import SearchTerm from "discourse/components/search-menu/search-term"; import SearchTerm from "discourse/components/search-menu/search-term";
import concatClass from "discourse/helpers/concat-class";
import loadingSpinner from "discourse/helpers/loading-spinner"; import loadingSpinner from "discourse/helpers/loading-spinner";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import { CANCELLED_STATUS } from "discourse/lib/autocomplete"; import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
@ -392,7 +393,9 @@ export default class SearchMenu extends Component {
{{! template-lint-disable no-invalid-interactive }} {{! template-lint-disable no-invalid-interactive }}
{{on "keydown" this.onKeydown}} {{on "keydown" this.onKeydown}}
> >
<div class="search-input"> <div
class={{concatClass "search-input" (concat "search-input--" @location)}}
>
{{#if this.search.inTopicContext}} {{#if this.search.inTopicContext}}
<DButton <DButton
@icon="xmark" @icon="xmark"

View file

@ -0,0 +1,90 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { modifier } from "ember-modifier";
import DButton from "discourse/components/d-button";
import PluginOutlet from "discourse/components/plugin-outlet";
import SearchMenu from "discourse/components/search-menu";
import bodyClass from "discourse/helpers/body-class";
import { prioritizeNameFallback } from "discourse/lib/settings";
import { i18n } from "discourse-i18n";

export default class WelcomeBanner extends Component {
@service router;
@service siteSettings;
@service currentUser;

@tracked inViewport = true;

checkViewport = modifier((element) => {
const observer = new IntersectionObserver(
([entry]) => {
this.inViewport = entry.isIntersecting;
},
{ threshold: 1.0 }
);

observer.observe(element);

return () => observer.disconnect();
});

get displayForRoute() {
return this.siteSettings.top_menu
.split("|")
.any(
(menuItem) => `discovery.${menuItem}` === this.router.currentRouteName
);
}

get headerText() {
if (!this.currentUser) {
return i18n("welcome_banner.header.anonymous_members", {
site_name: this.siteSettings.title,
});
}

return i18n("welcome_banner.header.logged_in_members", {
preferred_display_name: prioritizeNameFallback(
this.currentUser.name,
this.currentUser.username
),
});
}

get shouldDisplay() {
if (!this.siteSettings.enable_welcome_banner) {
return false;
}

return this.displayForRoute;
}

<template>
{{#if this.shouldDisplay}}
{{#if this.inViewport}}
{{bodyClass "welcome-banner--visible"}}
{{/if}}

<div class="welcome-banner" {{this.checkViewport}}>
<div class="custom-search-banner welcome-banner__inner-wrapper">
<div class="custom-search-banner-wrap welcome-banner__wrap">
<h1 class="welcome-banner__title">{{htmlSafe this.headerText}}</h1>
<PluginOutlet @name="welcome-banner-below-headline" />
<div class="search-menu welcome-banner__search-menu">
<DButton
@icon="magnifying-glass"
@title="search.open_advanced"
@href="/search?expanded=true"
class="search-icon"
/>
<SearchMenu @location="welcome-banner" />
</div>
<PluginOutlet @name="welcome-banner-below-input" />
</div>
</div>
</div>
{{/if}}
</template>
}

View file

@ -25,6 +25,7 @@ import RenderGlimmerContainer from "discourse/components/render-glimmer-containe
import Sidebar from "discourse/components/sidebar"; import Sidebar from "discourse/components/sidebar";
import SoftwareUpdatePrompt from "discourse/components/software-update-prompt"; import SoftwareUpdatePrompt from "discourse/components/software-update-prompt";
import TopicEntrance from "discourse/components/topic-entrance"; import TopicEntrance from "discourse/components/topic-entrance";
import WelcomeBanner from "discourse/components/welcome-banner";
import routeAction from "discourse/helpers/route-action"; import routeAction from "discourse/helpers/route-action";
import { i18n } from "discourse-i18n"; import { i18n } from "discourse-i18n";
import DMenus from "float-kit/components/d-menus"; import DMenus from "float-kit/components/d-menus";
@ -90,6 +91,9 @@ export default RouteTemplate(


<div id="main-outlet"> <div id="main-outlet">
<PluginOutlet @name="above-main-container" @connectorTagName="div" /> <PluginOutlet @name="above-main-container" @connectorTagName="div" />

<WelcomeBanner />

<div class="container" id="main-container"> <div class="container" id="main-container">
{{#if @controller.showTop}} {{#if @controller.showTop}}
<CustomHtml @name="top" /> <CustomHtml @name="top" />

View file

@ -98,16 +98,16 @@ acceptance("Search - Anonymous", function (needs) {
await visit("/"); await visit("/");


await click("#search-button"); await click("#search-button");
assert.dom(".search-menu").exists(); assert.dom(".search-menu-panel").exists();


await clickOutside(); await clickOutside();
assert.dom(".search-menu").doesNotExist(); assert.dom(".search-menu-panel").doesNotExist();


await click("#search-button"); await click("#search-button");
assert.dom(".search-menu").exists(); assert.dom(".search-menu-panel").exists();


await click("#search-button"); // toggle same button await click("#search-button"); // toggle same button
assert.dom(".search-menu").doesNotExist(); assert.dom(".search-menu-panel").doesNotExist();
}); });


test("initial options", async function (assert) { test("initial options", async function (assert) {
@ -572,7 +572,9 @@ acceptance("Search - Authenticated", function (needs) {
assert assert
.dom("#search-button") .dom("#search-button")
.isFocused("Escaping search returns focus to search button"); .isFocused("Escaping search returns focus to search button");
assert.dom(".search-menu").doesNotExist("Esc removes search dropdown"); assert
.dom(".search-menu-panel")
.doesNotExist("Esc removes search dropdown");


await click("#search-button"); await click("#search-button");
await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown"); await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");

View file

@ -29,7 +29,7 @@ module("Integration | Component | search-menu", function (hooks) {
return response(searchFixtures["search/query"]); return response(searchFixtures["search/query"]);
}); });


await render(<template><SearchMenu /></template>); await render(<template><SearchMenu @location="test" /></template>);


assert assert
.dom(".show-advanced-search") .dom(".show-advanced-search")
@ -73,7 +73,7 @@ module("Integration | Component | search-menu", function (hooks) {
test("clicking outside results hides and blurs input", async function (assert) { test("clicking outside results hides and blurs input", async function (assert) {
await render( await render(
<template> <template>
<div id="click-me"><SearchMenu /></div> <div id="click-me"><SearchMenu @location="test" /></div>
</template> </template>
); );
await click("#search-term"); await click("#search-term");

View file

@ -62,3 +62,4 @@
@import "emoji-picker"; @import "emoji-picker";
@import "filter-input"; @import "filter-input";
@import "dropdown-menu"; @import "dropdown-menu";
@import "welcome-banner";

View file

@ -0,0 +1,160 @@
@import "common/foundation/mixins";

.display-welcome-banner {
#main-outlet {
padding-top: 0;
}
}

.welcome-banner--visible.header-search--enabled {
.floating-search-input-wrapper {
display: none;
}
}

.welcome-banner {
margin-bottom: 1em;
border-radius: var(--d-border-radius-large);

&__search-menu {
.menu-panel-results .menu-panel.search-menu-panel {
position: unset;
padding: 0;
}
}

&__wrap {
box-sizing: border-box;
position: relative;
padding: 1.5em 0 3em;

@include breakpoint(tablet) {
padding: 1em 8px 1.25em;
margin-top: 0.5em;
}

> div {
margin: 0 auto;
max-width: 600px;
}

.search-menu {
display: flex;
position: relative;

.search-menu-container {
width: 100%;
min-width: 0;
}
}

.d-icon-search {
margin: 0;
}

.browser-search-tip {
display: none;
}

.search-input {
#search-term {
min-width: 0;
flex: 1 1;
}
}

h1 {
font-size: var(--font-up-6);
line-height: $line-height-medium;

@include breakpoint(tablet) {
font-size: var(--font-up-4);
}
}

h1,
p {
text-align: center;
}

.btn.search-icon {
z-index: 2;
background: transparent;
line-height: 1;
color: var(--primary-medium);
height: 100%;
position: absolute;
left: 0;

.rtl & {
right: 0;
left: unset;
}

.discourse-no-touch & {
&:hover {
background: transparent;
color: var(--primary);

.d-icon {
color: currentcolor;
}
}
}

+ .search-menu-container .search-input {
padding-left: 1.75em;

.rtl & {
padding-left: unset;
padding-right: 1.75em;
}
}

+ .search-menu-container .search-input .search-context {
margin-left: 4px;
}
}

.results {
box-sizing: border-box;
background: var(--secondary);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.15);
position: absolute;
z-index: 9;
margin-left: auto;
margin-right: auto;
left: 0;
top: 2.75em;
right: 0;
padding: 0.5em;

@include breakpoint(mobile-extra-large) {
width: 100%;
}

ul,
ol {
list-style-type: none;
margin: 0;
}

.d-icon-search {
display: none;
}
}

.search-link .d-icon {
color: var(--primary-medium);
}

span.keyword {
color: var(--primary);
}
}
}

// hide search icon from default search menu
.search-menu.glimmer-search-menu .search-icon {
display: none;
}

View file

@ -1,4 +1,4 @@
.header-search--visible, .header-search--enabled,
.search-header--visible { .search-header--visible {
.panel .header-dropdown-toggle.search-dropdown, .panel .header-dropdown-toggle.search-dropdown,
.panel .search-menu { .panel .search-menu {

View file

@ -4,3 +4,4 @@
@import "welcome-header"; @import "welcome-header";
@import "more-topics"; @import "more-topics";
@import "emoji-picker"; @import "emoji-picker";
@import "welcome-banner";

View file

@ -0,0 +1,9 @@
.welcome-banner__search-menu {
.search-menu .search-link .badge-category {
display: inline-block;
}

.search-menu .search-input input#search-term {
width: 100%;
}
}

View file

@ -218,6 +218,12 @@ en:
bootstrap_mode: "Getting started" bootstrap_mode: "Getting started"
back_button: "Back" back_button: "Back"


welcome_banner:
header:
logged_in_members: "Welcome back, %{preferred_display_name}!"
anonymous_members: "Welcome to %{site_name}!"
search: "Search"

themes: themes:
default_description: "Default" default_description: "Default"
broken_theme_alert: "Your site may not work because a theme / component has errors." broken_theme_alert: "Your site may not work because a theme / component has errors."

View file

@ -2743,6 +2743,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)." 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_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." show_topic_map_in_topics_without_replies: "Shows the topic map even if the topic has no replies."
enable_welcome_banner: "Display a banner on your main topic list pages to welcome members and allow them to search site content"


splash_screen: "Displays a temporary loading screen while site assets load" splash_screen: "Displays a temporary loading screen while site assets load"
navigation_menu: "Specify sidebar or header dropdown as the main navigation menu for your site. Sidebar is recommended." navigation_menu: "Specify sidebar or header dropdown as the main navigation menu for your site. Sidebar is recommended."

View file

@ -3199,6 +3199,10 @@ uncategorized:
client: true client: true
default: true default: true


enable_welcome_banner:
client: true
default: true

user_preferences: user_preferences:
default_email_digest_frequency: default_email_digest_frequency:
enum: "DigestEmailSiteSetting" enum: "DigestEmailSiteSetting"

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
#
class EnableWelcomeBannerNewSites < ActiveRecord::Migration[7.2]
def up
execute <<~SQL if Migration::Helpers.existing_site?
INSERT INTO site_settings(name, data_type, value, created_at, updated_at)
VALUES('enable_welcome_banner', 5, 'f', NOW(), NOW())
ON CONFLICT (name) DO NOTHING
SQL
end

def down
raise ActiveRecord::IrreversibleMigration
end
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true

module PageObjects
module Components
class WelcomeBanner < PageObjects::Components::Base
def visible?
has_css?(".welcome-banner")
end

def hidden?
has_no_css?(".welcome-banner")
end

def invisible?
has_css?(".welcome-banner", visible: false)
end

def has_anonymous_title?
has_css?(
".welcome-banner .welcome-banner__title",
text: I18n.t("js.welcome_banner.header.anonymous_members", site_name: SiteSetting.title),
)
end

def has_logged_in_title?(username)
has_css?(
".welcome-banner .welcome-banner__title",
text:
I18n.t("js.welcome_banner.header.logged_in_members", preferred_display_name: username),
)
end
end
end
end

View file

@ -9,7 +9,7 @@ module PageObjects
end end


def type_in_search_menu(input) def type_in_search_menu(input)
find("input#search-term").send_keys(input) find(".search-input--header input").send_keys(input)
self self
end end



View file

@ -14,6 +14,7 @@ describe "Search", type: :system do
SearchIndexer.enable SearchIndexer.enable
SearchIndexer.index(topic, force: true) SearchIndexer.index(topic, force: true)
SearchIndexer.index(topic2, force: true) SearchIndexer.index(topic2, force: true)
SiteSetting.enable_welcome_banner = false
end end


after { SearchIndexer.disable } after { SearchIndexer.disable }
@ -68,6 +69,7 @@ describe "Search", type: :system do
SearchIndexer.index(topic, force: true) SearchIndexer.index(topic, force: true)
SiteSetting.rate_limit_search_anon_user_per_minute = 4 SiteSetting.rate_limit_search_anon_user_per_minute = 4
RateLimiter.enable RateLimiter.enable
SiteSetting.enable_welcome_banner = false
end end


after { SearchIndexer.disable } after { SearchIndexer.disable }
@ -93,6 +95,7 @@ describe "Search", type: :system do
SearchIndexer.enable SearchIndexer.enable
SearchIndexer.index(topic, force: true) SearchIndexer.index(topic, force: true)
SearchIndexer.index(topic2, force: true) SearchIndexer.index(topic2, force: true)
SiteSetting.enable_welcome_banner = false
end end


after { SearchIndexer.disable } after { SearchIndexer.disable }
@ -170,6 +173,7 @@ describe "Search", type: :system do
SearchIndexer.enable SearchIndexer.enable
SearchIndexer.index(topic, force: true) SearchIndexer.index(topic, force: true)
SearchIndexer.index(topic2, force: true) SearchIndexer.index(topic2, force: true)
SiteSetting.enable_welcome_banner = false
sign_in(admin) sign_in(admin)
end end



View file

@ -0,0 +1,73 @@
# frozen_string_literal: true

describe "Welcome banner", type: :system do
fab!(:current_user) { Fabricate(:user) }
let(:banner) { PageObjects::Components::WelcomeBanner.new }
let(:search_page) { PageObjects::Pages::Search.new }

context "when welcome banner is enabled" do
before { SiteSetting.enable_welcome_banner = true }

it "shows for logged in and anonymous users" do
visit "/"
expect(banner).to be_visible
expect(banner).to have_anonymous_title
sign_in(current_user)
visit "/"
expect(banner).to have_logged_in_title(current_user.username)
end

it "only displays on top_menu routes" do
sign_in(current_user)
SiteSetting.remove_override!(:top_menu)
topic = Fabricate(:topic)
visit "/"
expect(banner).to be_visible
visit "/latest"
expect(banner).to be_visible
visit "/new"
expect(banner).to be_visible
visit "/unread"
expect(banner).to be_visible
visit "/hot"
expect(banner).to be_visible
visit "/tags"
expect(banner).to be_hidden
visit topic.relative_url
expect(banner).to be_hidden
end

it "hides welcome banner and shows header search on scroll, and vice-versa" do
SiteSetting.search_experience = "search_field"
Fabricate(:topic)

sign_in(current_user)
visit "/"
expect(banner).to be_visible
expect(search_page).to have_no_search_field

# Trick to give a huge vertical space to scroll
page.execute_script("document.querySelector('.topic-list').style.height = '10000px'")
page.scroll_to(0, 1000)

expect(banner).to be_invisible
expect(search_page).to have_search_field

page.scroll_to(0, 0)
expect(banner).to be_visible
expect(search_page).to have_no_search_field
end
end

context "when welcome banner is not enabled" do
before { SiteSetting.enable_welcome_banner = false }

it "does not show the welcome banner for logged in and anonymous users" do
visit "/"
expect(banner).to be_hidden
sign_in(current_user)
visit "/"
expect(banner).to be_hidden
end
end
end