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:
parent
c5e95b419b
commit
64f1b97e0c
20 changed files with 421 additions and 13 deletions
|
@ -29,7 +29,7 @@ export default class HeaderSearch extends Component {
|
|||
|
||||
<template>
|
||||
{{#if this.shouldDisplay}}
|
||||
{{bodyClass "header-search--visible"}}
|
||||
{{bodyClass "header-search--enabled"}}
|
||||
<div
|
||||
class="floating-search-input-wrapper"
|
||||
{{this.handleKeyboardShortcut}}
|
||||
|
@ -46,7 +46,7 @@ export default class HeaderSearch extends Component {
|
|||
@href={{this.advancedSearchButtonHref}}
|
||||
/>
|
||||
|
||||
<SearchMenu />
|
||||
<SearchMenu @location="header" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -21,6 +21,7 @@ export default class SearchMenuPanel extends Component {
|
|||
@onClose={{@closeSearchMenu}}
|
||||
@inlineResults={{true}}
|
||||
@autofocusInput={{true}}
|
||||
@location="header"
|
||||
/>
|
||||
</MenuPanel>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import { concat, hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
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 Results from "discourse/components/search-menu/results";
|
||||
import SearchTerm from "discourse/components/search-menu/search-term";
|
||||
import concatClass from "discourse/helpers/concat-class";
|
||||
import loadingSpinner from "discourse/helpers/loading-spinner";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
|
||||
|
@ -392,7 +393,9 @@ export default class SearchMenu extends Component {
|
|||
{{! template-lint-disable no-invalid-interactive }}
|
||||
{{on "keydown" this.onKeydown}}
|
||||
>
|
||||
<div class="search-input">
|
||||
<div
|
||||
class={{concatClass "search-input" (concat "search-input--" @location)}}
|
||||
>
|
||||
{{#if this.search.inTopicContext}}
|
||||
<DButton
|
||||
@icon="xmark"
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -25,6 +25,7 @@ import RenderGlimmerContainer from "discourse/components/render-glimmer-containe
|
|||
import Sidebar from "discourse/components/sidebar";
|
||||
import SoftwareUpdatePrompt from "discourse/components/software-update-prompt";
|
||||
import TopicEntrance from "discourse/components/topic-entrance";
|
||||
import WelcomeBanner from "discourse/components/welcome-banner";
|
||||
import routeAction from "discourse/helpers/route-action";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DMenus from "float-kit/components/d-menus";
|
||||
|
@ -90,6 +91,9 @@ export default RouteTemplate(
|
|||
|
||||
<div id="main-outlet">
|
||||
<PluginOutlet @name="above-main-container" @connectorTagName="div" />
|
||||
|
||||
<WelcomeBanner />
|
||||
|
||||
<div class="container" id="main-container">
|
||||
{{#if @controller.showTop}}
|
||||
<CustomHtml @name="top" />
|
||||
|
|
|
@ -98,16 +98,16 @@ acceptance("Search - Anonymous", function (needs) {
|
|||
await visit("/");
|
||||
|
||||
await click("#search-button");
|
||||
assert.dom(".search-menu").exists();
|
||||
assert.dom(".search-menu-panel").exists();
|
||||
|
||||
await clickOutside();
|
||||
assert.dom(".search-menu").doesNotExist();
|
||||
assert.dom(".search-menu-panel").doesNotExist();
|
||||
|
||||
await click("#search-button");
|
||||
assert.dom(".search-menu").exists();
|
||||
assert.dom(".search-menu-panel").exists();
|
||||
|
||||
await click("#search-button"); // toggle same button
|
||||
assert.dom(".search-menu").doesNotExist();
|
||||
assert.dom(".search-menu-panel").doesNotExist();
|
||||
});
|
||||
|
||||
test("initial options", async function (assert) {
|
||||
|
@ -572,7 +572,9 @@ acceptance("Search - Authenticated", function (needs) {
|
|||
assert
|
||||
.dom("#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 triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
|
||||
|
|
|
@ -29,7 +29,7 @@ module("Integration | Component | search-menu", function (hooks) {
|
|||
return response(searchFixtures["search/query"]);
|
||||
});
|
||||
|
||||
await render(<template><SearchMenu /></template>);
|
||||
await render(<template><SearchMenu @location="test" /></template>);
|
||||
|
||||
assert
|
||||
.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) {
|
||||
await render(
|
||||
<template>
|
||||
<div id="click-me"><SearchMenu /></div>
|
||||
<div id="click-me"><SearchMenu @location="test" /></div>
|
||||
</template>
|
||||
);
|
||||
await click("#search-term");
|
||||
|
|
|
@ -62,3 +62,4 @@
|
|||
@import "emoji-picker";
|
||||
@import "filter-input";
|
||||
@import "dropdown-menu";
|
||||
@import "welcome-banner";
|
||||
|
|
160
app/assets/stylesheets/common/components/welcome-banner.scss
Normal file
160
app/assets/stylesheets/common/components/welcome-banner.scss
Normal 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;
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
.header-search--visible,
|
||||
.header-search--enabled,
|
||||
.search-header--visible {
|
||||
.panel .header-dropdown-toggle.search-dropdown,
|
||||
.panel .search-menu {
|
||||
|
|
|
@ -4,3 +4,4 @@
|
|||
@import "welcome-header";
|
||||
@import "more-topics";
|
||||
@import "emoji-picker";
|
||||
@import "welcome-banner";
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -218,6 +218,12 @@ en:
|
|||
bootstrap_mode: "Getting started"
|
||||
back_button: "Back"
|
||||
|
||||
welcome_banner:
|
||||
header:
|
||||
logged_in_members: "Welcome back, %{preferred_display_name}!"
|
||||
anonymous_members: "Welcome to %{site_name}!"
|
||||
search: "Search"
|
||||
|
||||
themes:
|
||||
default_description: "Default"
|
||||
broken_theme_alert: "Your site may not work because a theme / component has errors."
|
||||
|
|
|
@ -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)."
|
||||
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_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"
|
||||
navigation_menu: "Specify sidebar or header dropdown as the main navigation menu for your site. Sidebar is recommended."
|
||||
|
|
|
@ -3199,6 +3199,10 @@ uncategorized:
|
|||
client: true
|
||||
default: true
|
||||
|
||||
enable_welcome_banner:
|
||||
client: true
|
||||
default: true
|
||||
|
||||
user_preferences:
|
||||
default_email_digest_frequency:
|
||||
enum: "DigestEmailSiteSetting"
|
||||
|
|
15
db/migrate/20250311073009_enable_welcome_banner_new_sites.rb
Normal file
15
db/migrate/20250311073009_enable_welcome_banner_new_sites.rb
Normal 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
|
34
spec/system/page_objects/components/welcome_banner.rb
Normal file
34
spec/system/page_objects/components/welcome_banner.rb
Normal 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
|
|
@ -9,7 +9,7 @@ module PageObjects
|
|||
end
|
||||
|
||||
def type_in_search_menu(input)
|
||||
find("input#search-term").send_keys(input)
|
||||
find(".search-input--header input").send_keys(input)
|
||||
self
|
||||
end
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ describe "Search", type: :system do
|
|||
SearchIndexer.enable
|
||||
SearchIndexer.index(topic, force: true)
|
||||
SearchIndexer.index(topic2, force: true)
|
||||
SiteSetting.enable_welcome_banner = false
|
||||
end
|
||||
|
||||
after { SearchIndexer.disable }
|
||||
|
@ -68,6 +69,7 @@ describe "Search", type: :system do
|
|||
SearchIndexer.index(topic, force: true)
|
||||
SiteSetting.rate_limit_search_anon_user_per_minute = 4
|
||||
RateLimiter.enable
|
||||
SiteSetting.enable_welcome_banner = false
|
||||
end
|
||||
|
||||
after { SearchIndexer.disable }
|
||||
|
@ -93,6 +95,7 @@ describe "Search", type: :system do
|
|||
SearchIndexer.enable
|
||||
SearchIndexer.index(topic, force: true)
|
||||
SearchIndexer.index(topic2, force: true)
|
||||
SiteSetting.enable_welcome_banner = false
|
||||
end
|
||||
|
||||
after { SearchIndexer.disable }
|
||||
|
@ -170,6 +173,7 @@ describe "Search", type: :system do
|
|||
SearchIndexer.enable
|
||||
SearchIndexer.index(topic, force: true)
|
||||
SearchIndexer.index(topic2, force: true)
|
||||
SiteSetting.enable_welcome_banner = false
|
||||
sign_in(admin)
|
||||
end
|
||||
|
||||
|
|
73
spec/system/welcome_banner_spec.rb
Normal file
73
spec/system/welcome_banner_spec.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue