FEATURE: add plugin API for homepage options

This commit is contained in:
Jake Goldsborough
2026-08-31 21:22:59 +00:00
committed by ducks
parent f5e04de2c8
commit d87905bbca
27 changed files with 591 additions and 29 deletions
+8 -1
View File
@@ -10,7 +10,14 @@ class HomepageSiteSetting < EnumSiteSetting
def self.values
# A blank value means the homepage is derived from the first top_menu item.
[{ name: "admin.homepage.top_menu_default", value: "" }] +
TopMenu.homepage_choices.map { |f| { name: "filters.#{f}.title", value: f } }
TopMenu.homepage_choices.map { |f| { name: "filters.#{f}.title", value: f } } +
DiscoursePluginRegistry.homepage_options.map do |option|
{ name: option[:name], value: option[:id] }
end
end
def self.choices
values.filter_map { |entry| entry[:value].presence }
end
def self.translate_names?
+6 -1
View File
@@ -191,7 +191,7 @@ class SiteSetting < ActiveRecord::Base
def self.homepage
configured = default_homepage.presence
if configured && TopMenu.homepage_choices.include?(configured)
if configured && HomepageSiteSetting.choices.include?(configured)
configured
else
top_menu_items[0].name
@@ -204,6 +204,11 @@ class SiteSetting < ActiveRecord::Base
def self.anonymous_homepage
return homepage if anonymous_menu_items.include?(homepage)
if DiscoursePluginRegistry.homepage_options.any? { |option|
option[:id] == homepage && option[:anonymous]
}
return homepage
end
top_menu_items
.map { |item| item.name }
+6 -1
View File
@@ -13,6 +13,7 @@ class SiteSerializer < ApplicationSerializer
:filters,
:anonymous_list_filters,
:homepage_choices,
:homepage_options,
:periods,
:top_menu_items,
:anonymous_top_menu_items,
@@ -230,7 +231,11 @@ class SiteSerializer < ApplicationSerializer
end
def homepage_choices
TopMenu.homepage_choices
HomepageSiteSetting.choices
end
def homepage_options
DiscoursePluginRegistry.homepage_options.map { |option| option.slice(:id, :path, :server_side) }
end
def periods
+5
View File
@@ -1896,6 +1896,11 @@ Discourse::Application.routes.draw do
as: "list_#{filter}"
end
DiscoursePluginRegistry._raw_homepage_options.each do |registration|
option = registration[:value]
get "/", to: option[:route], constraints: HomePageConstraint.new(option[:id])
end
get "/t/:topic_id/view-stats.json" => "topic_view_stats#index"
# special case for categories
@@ -7,7 +7,7 @@ import GroupFlairVisibilityWarning from "discourse/components/group-flair-visibi
import GroupDefaultNotificationsModal from "discourse/components/modal/group-default-notifications";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { GROUP_VISIBILITY_LEVELS } from "discourse/lib/constants";
import { defaultHomepage } from "discourse/lib/utilities";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import { or } from "discourse/truth-helpers";
import DButton from "discourse/ui-kit/d-button";
import { i18n } from "discourse-i18n";
@@ -85,7 +85,7 @@ export default class GroupManageSaveButton extends Component {
await group.save(opts);
if (lostAccess) {
this.router.transitionTo(`discovery.${defaultHomepage()}`);
this.router.transitionTo(homepageNavigationDestination());
return;
}
@@ -1,4 +1,5 @@
import { defaultHomepage } from "discourse/lib/utilities";
import Site from "discourse/models/site";
/**
* We want / to display one of our discovery routes/controllers, but we don't
@@ -28,13 +29,55 @@ export const homepageRewriteParam = "_discourse_homepage_rewrite";
* We watch for this, and then perform the rewrite in the router.
*/
export function homepageDestination() {
return `/${defaultHomepage()}?${homepageRewriteParam}=1`;
if (serverSideHomepage()) {
return "/";
}
return `${homepagePath()}?${homepageRewriteParam}=1`;
}
export function homepageNavigationDestination() {
const option = registeredHomepageOption();
if (!option) {
return `discovery.${defaultHomepage()}`;
}
return option.server_side ? "/" : option.path;
}
export function homepagePreviewDestination() {
const option = registeredHomepageOption();
if (!option) {
return `discovery.${defaultHomepage()}`;
}
return option.server_side ? "discovery.latest" : option.path;
}
export function homepagePath() {
const homepage = defaultHomepage();
const option = registeredHomepageOption();
return option?.path || `/${homepage}`;
}
export function serverSideHomepage() {
return registeredHomepageOption()?.server_side === true;
}
function registeredHomepageOption() {
const homepage = defaultHomepage();
return Site.current()?.homepage_options?.find(({ id }) => id === homepage);
}
function rewriteIfNeeded(url, transition) {
const intentUrl = transition?.intent?.url;
if (
intentUrl?.startsWith(homepageDestination()) ||
(homepageDestination() !== "/" &&
intentUrl?.startsWith(homepageDestination())) ||
intentUrl?.startsWith("/login-required") ||
(transition?.intent.name === `discovery.${defaultHomepage()}` &&
transition?.intent.queryParams[homepageRewriteParam])
+12 -4
View File
@@ -13,6 +13,7 @@ import { applyValueTransformer } from "discourse/lib/transformer";
import { defaultHomepage } from "discourse/lib/utilities";
import Category from "discourse/models/category";
import Session from "discourse/models/session";
import Site from "discourse/models/site";
const rewrites = [];
export const TOPIC_URL_REGEXP = /\/t\/([^\/]*[^\d\/][^\/]*)\/(\d+)\/?(\d+)?/;
@@ -268,10 +269,17 @@ class DiscourseURL extends EmberObject {
return this.redirectTo(path);
}
const pathnameWithoutPrefix = withoutPrefix(pathname);
const serverSide = SERVER_SIDE_ONLY.some((r) =>
pathnameWithoutPrefix.match(r)
);
const pathnameWithoutPrefix = withoutPrefix(pathname).split(/[?#]/, 1)[0];
const registeredServerSidePath = Site.current()
?.homepage_options?.filter(({ server_side }) => server_side)
.some(
({ path: homepagePath }) =>
pathnameWithoutPrefix === homepagePath ||
pathnameWithoutPrefix.startsWith(`${homepagePath}/`)
);
const serverSide =
registeredServerSidePath ||
SERVER_SIDE_ONLY.some((r) => pathnameWithoutPrefix.match(r));
if (serverSide) {
this.redirectTo(path);
return;
@@ -2,8 +2,10 @@ import { service } from "@ember/service";
import {
homepageDestination,
homepageRewriteParam,
serverSideHomepage,
} from "discourse/lib/homepage-router-overrides";
import { disableImplicitInjections } from "discourse/lib/implicit-injections";
import DiscourseURL from "discourse/lib/url";
import DiscourseRoute from "../discourse";
@disableImplicitInjections
@@ -13,6 +15,11 @@ export default class DiscoveryIndex extends DiscourseRoute {
@service siteSettings;
beforeModel(transition) {
if (serverSideHomepage()) {
DiscourseURL.redirectTo("/");
return;
}
const url = transition.intent.url;
const params = url?.split("?", 2)[1];
let destination = homepageDestination();
@@ -1,7 +1,7 @@
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import ForgotPassword from "discourse/components/modal/forgot-password";
import { defaultHomepage } from "discourse/lib/utilities";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import DiscourseRoute from "discourse/routes/discourse";
export default class ForgotPasswordRoute extends DiscourseRoute {
@@ -12,7 +12,7 @@ export default class ForgotPasswordRoute extends DiscourseRoute {
const { loginRequired } = this.controllerFor("application");
await this.router.replaceWith(
loginRequired ? "login" : `discovery.${defaultHomepage()}`
loginRequired ? "login" : homepageNavigationDestination()
);
next(() => this.modal.show(ForgotPassword));
}
+4 -2
View File
@@ -1,9 +1,9 @@
import { service } from "@ember/service";
import cookie from "discourse/lib/cookie";
import getURL from "discourse/lib/get-url";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import DiscourseURL from "discourse/lib/url";
import {
defaultHomepage,
isValidDestinationUrl,
postRNWebviewMessage,
} from "discourse/lib/utilities";
@@ -29,7 +29,9 @@ export default class extends DiscourseRoute {
const { referrer } = document;
const { isOnlyOneExternalLoginMethod, singleExternalLogin } = this.login;
const redirect = auth_immediately || login_required || !from || wantsTo;
const homepage = `discovery.${login_required ? "login-required" : defaultHomepage()}`;
const homepage = login_required
? "discovery.login-required"
: homepageNavigationDestination();
// Regular users can't log in but staff can when the site is read-only
if (isReadOnly && !isStaffWritesOnly) {
+2 -2
View File
@@ -1,8 +1,8 @@
import { trackedArray } from "@ember/reactive/collections";
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import { showCreateInviteModal } from "discourse/lib/invite-modal";
import { defaultHomepage } from "discourse/lib/utilities";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
@@ -26,7 +26,7 @@ export default class extends DiscourseRoute {
// when landing on the route from a full page load
this.router
.replaceWith(`discovery.${defaultHomepage()}`)
.replaceWith(homepageNavigationDestination())
.followRedirects()
.then(() => this.#openInviteModalIfAllowed());
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { defaultHomepage } from "discourse/lib/utilities";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import Group from "discourse/models/group";
import DiscourseRoute from "discourse/routes/discourse";
import { i18n } from "discourse-i18n";
@@ -27,7 +27,7 @@ export default class extends DiscourseRoute {
// when landing on the route from a full page load
this.router
.replaceWith(`discovery.${defaultHomepage()}`)
.replaceWith(homepageNavigationDestination())
.followRedirects()
.then(() => this.#openComposerWithPrefilledValues(params));
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { next } from "@ember/runloop";
import { service } from "@ember/service";
import { defaultHomepage } from "discourse/lib/utilities";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import Category from "discourse/models/category";
import DiscourseRoute from "discourse/routes/discourse";
@@ -47,7 +47,7 @@ export default class extends DiscourseRoute {
// When landing on the route from a full page load
this.router
.replaceWith(`discovery.${defaultHomepage()}`)
.replaceWith(homepageNavigationDestination())
.followRedirects()
.then(() => {
if (this.currentUser.can_create_topic) {
@@ -1,6 +1,6 @@
import { service } from "@ember/service";
import ShareTargetModal from "discourse/components/modal/share-target";
import { defaultHomepage } from "discourse/lib/utilities";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import DiscourseRoute from "discourse/routes/discourse";
export default class extends DiscourseRoute {
@@ -31,7 +31,7 @@ export default class extends DiscourseRoute {
// The share-target route has no UI of its own — send the user to the
// homepage; the modal (if any) opens once that page has rendered.
this.router.replaceWith(`discovery.${defaultHomepage()}`);
this.router.replaceWith(homepageNavigationDestination());
}
#hasContent({ title, text, url, files }) {
+4 -2
View File
@@ -1,9 +1,9 @@
import { service } from "@ember/service";
import cookie from "discourse/lib/cookie";
import getURL from "discourse/lib/get-url";
import { homepageNavigationDestination } from "discourse/lib/homepage-router-overrides";
import DiscourseURL from "discourse/lib/url";
import {
defaultHomepage,
isValidDestinationUrl,
postRNWebviewMessage,
} from "discourse/lib/utilities";
@@ -34,7 +34,9 @@ export default class extends DiscourseRoute {
const { canSignUp } = this.controllerFor("application");
const { isOnlyOneExternalLoginMethod, singleExternalLogin } = this.login;
const redirect = auth_immediately || login_required || !from || wantsTo;
const homepage = `discovery.${login_required ? "login-required" : defaultHomepage()}`;
const homepage = login_required
? "discovery.login-required"
: homepageNavigationDestination();
// Can't sign up when the site is read-only
if (isReadOnly) {
@@ -15,10 +15,10 @@ import {
clearPreview,
} from "discourse/lib/design-wizard-preview";
import { isTesting } from "discourse/lib/environment";
import { homepagePreviewDestination } from "discourse/lib/homepage-router-overrides";
import discourseLater from "discourse/lib/later";
import { HORIZON_THEME_ID, setLocalTheme } from "discourse/lib/theme-selector";
import DiscourseURL from "discourse/lib/url";
import { defaultHomepage } from "discourse/lib/utilities";
const STATE_KEY = "design_wizard_panel_state";
// site settings the wizard mutates locally to preview a selection, and which
@@ -170,7 +170,7 @@ export default class DesignWizardService extends Service {
return;
}
await this.router.transitionTo(`discovery.${defaultHomepage()}`);
await this.router.transitionTo(homepagePreviewDestination());
await this.start({ source: SOURCE_ADMIN, returnUrl });
}
+1
View File
@@ -63,6 +63,7 @@ const siteFixtures = {
"hot",
"unread"
],
homepage_options: [],
periods: ["all", "yearly", "quarterly", "monthly", "weekly", "daily"],
top_menu_items: [
"latest",
@@ -0,0 +1,57 @@
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import {
homepageDestination,
homepageNavigationDestination,
homepagePath,
homepagePreviewDestination,
} from "discourse/lib/homepage-router-overrides";
import { setDefaultHomepage } from "discourse/lib/utilities";
import Site from "discourse/models/site";
module("Unit | Lib | homepage-router-overrides", function (hooks) {
setupTest(hooks);
hooks.afterEach(function () {
Site.current().set("homepage_options", []);
});
test("uses the conventional filter path for core homepages", function (assert) {
setDefaultHomepage("latest");
assert.strictEqual(homepagePath(), "/latest");
assert.strictEqual(homepageNavigationDestination(), "discovery.latest");
assert.strictEqual(homepagePreviewDestination(), "discovery.latest");
assert.strictEqual(
homepageDestination(),
"/latest?_discourse_homepage_rewrite=1"
);
});
test("uses the path supplied by a registered homepage", function (assert) {
Site.current().set("homepage_options", [
{ id: "directory", path: "/directory" },
]);
setDefaultHomepage("directory");
assert.strictEqual(homepagePath(), "/directory");
assert.strictEqual(homepageNavigationDestination(), "/directory");
assert.strictEqual(homepagePreviewDestination(), "/directory");
assert.strictEqual(
homepageDestination(),
"/directory?_discourse_homepage_rewrite=1"
);
});
test("returns the site root for a server-rendered homepage", function (assert) {
Site.current().set("homepage_options", [
{ id: "directory", path: "/directory", server_side: true },
]);
setDefaultHomepage("directory");
assert.strictEqual(homepagePath(), "/directory");
assert.strictEqual(homepageDestination(), "/");
assert.strictEqual(homepageNavigationDestination(), "/");
assert.strictEqual(homepagePreviewDestination(), "discovery.latest");
});
});
@@ -13,6 +13,7 @@ import DiscourseURL, {
userPath,
} from "discourse/lib/url";
import Session from "discourse/models/session";
import Site from "discourse/models/site";
import { logIn } from "discourse/tests/helpers/qunit-helpers";
module("Unit | Utility | url", function (hooks) {
@@ -372,6 +373,33 @@ module("Unit | Utility | url", function (hooks) {
}
});
test("routeTo redirects paths registered as server-rendered homepages", function (assert) {
const site = Site.current();
const originalOptions = site.homepage_options;
site.set("homepage_options", [
{ id: "directory", path: "/directory", server_side: true },
]);
sinon.stub(DiscourseURL, "redirectTo");
try {
for (const path of [
"/directory/people/1/example",
"/directory?neighborhood=mississippi",
"/directory#shops",
]) {
DiscourseURL.redirectTo.resetHistory();
DiscourseURL.routeTo(path);
assert.true(
DiscourseURL.redirectTo.calledWith(path),
`${path} is redirected to the server`
);
}
} finally {
site.set("homepage_options", originalOptions);
}
});
test("routeTo full page loads when a refresh is required", async function (assert) {
sinon.stub(DiscourseURL, "isComposerOpen").get(() => false);
sinon.stub(DiscourseURL, "redirectTo");
+2
View File
@@ -144,6 +144,8 @@ class DiscoursePluginRegistry
define_filtered_register :calendar_subscription_feeds
define_filtered_register :homepage_options
define_filtered_register :custom_filter_mappings
define_filtered_register :acl_target_classes
+50
View File
@@ -1106,6 +1106,56 @@ class Plugin::Instance
)
end
# Registers a plugin page as an option for the default_homepage site setting.
# The route is also mounted at `/` when the option is selected, while `path`
# remains the page's canonical URL for direct navigation.
#
# @param id [String, Symbol] stable identifier stored in the site setting
# @param name [String] client-side translation key used in the admin setting
# @param path [String] application path for the homepage
# @param route [String] Rails controller action, in `controller#action` form
# @param anonymous [Boolean] whether logged-out visitors may use this homepage
# @param server_side [Boolean] whether navigation requires a full page request
def register_homepage(id, name:, path:, route:, anonymous: false, server_side: false)
id = id.to_s
if !id.match?(/\A[a-z0-9][a-z0-9_-]*\z/)
raise ArgumentError,
"homepage id must contain only lowercase letters, numbers, underscores, and hyphens"
end
raise ArgumentError, "homepage name must be present" if name.blank?
raise ArgumentError, "homepage path must start with /" if !path.to_s.start_with?("/")
if !route.to_s.match?(/\A[^#]+#[^#]+\z/)
raise ArgumentError, "homepage route must use controller#action format"
end
if ![true, false].include?(anonymous)
raise ArgumentError, "homepage anonymous must be true or false"
end
if ![true, false].include?(server_side)
raise ArgumentError, "homepage server_side must be true or false"
end
registered_ids =
DiscoursePluginRegistry._raw_homepage_options.map { |entry| entry[:value][:id] }
core_homepage_ids =
Discourse.filters.map(&:to_s) + %w[categories custom blank finish_installation]
if core_homepage_ids.include?(id) || registered_ids.include?(id)
raise ArgumentError, "homepage id '#{id}' is already registered"
end
DiscoursePluginRegistry.register_homepage_option(
{
id: id,
name: name,
path: path.to_s,
route: route.to_s,
anonymous: anonymous,
server_side: server_side,
},
self,
)
end
# Register a new demon process to be forked by the Unicorn master.
# The demon_class should inherit from Demon::Base.
# With great power comes great responsibility - this method should
+94
View File
@@ -1178,6 +1178,100 @@ TEXT
end
end
describe "#register_homepage" do
before { plugin_instance.stubs(:enabled?).returns(true) }
it "registers a homepage while the plugin is enabled" do
plugin_instance.register_homepage(
:sample_homepage,
name: "sample_plugin.homepage.title",
path: "/sample-homepage",
route: "sample_plugin/homepage#index",
anonymous: true,
)
expect(DiscoursePluginRegistry.homepage_options).to contain_exactly(
{
id: "sample_homepage",
name: "sample_plugin.homepage.title",
path: "/sample-homepage",
route: "sample_plugin/homepage#index",
anonymous: true,
server_side: false,
},
)
plugin_instance.stubs(:enabled?).returns(false)
expect(DiscoursePluginRegistry.homepage_options).to be_empty
end
it "rejects invalid and duplicate registrations" do
expect do
plugin_instance.register_homepage(
"not valid",
name: "plugin.homepage",
path: "/plugin",
route: "plugin#index",
)
end.to raise_error(ArgumentError, /homepage id/)
expect do
plugin_instance.register_homepage(
"latest",
name: "plugin.latest",
path: "/plugin-latest",
route: "plugin#latest",
)
end.to raise_error(ArgumentError, /already registered/)
expect do
plugin_instance.register_homepage(
"other_homepage",
name: "plugin.other_homepage",
path: "/other",
route: "plugin#other",
server_side: nil,
)
end.to raise_error(ArgumentError, /server_side/)
plugin_instance.register_homepage(
"sample_homepage",
name: "plugin.homepage",
path: "/sample-homepage",
route: "plugin#index",
)
expect do
plugin_instance.register_homepage(
"sample_homepage",
name: "plugin.other_homepage",
path: "/other",
route: "plugin#other",
)
end.to raise_error(ArgumentError, /already registered/)
end
it "allows distinct IDs that normalize to the same Rails helper name" do
plugin_instance.register_homepage(
"sample-homepage",
name: "plugin.hyphenated",
path: "/hyphenated",
route: "plugin#hyphenated",
)
plugin_instance.register_homepage(
"sample_homepage",
name: "plugin.underscored",
path: "/underscored",
route: "plugin#underscored",
)
expect(DiscoursePluginRegistry.homepage_options.pluck(:id)).to include(
"sample-homepage",
"sample_homepage",
)
end
end
describe "#register_admin_dashboard_section" do
let(:plugin) { Plugin::Instance.new }
+25
View File
@@ -1,6 +1,12 @@
# frozen_string_literal: true
describe HomepageSiteSetting do
around do |example|
registrations = DiscoursePluginRegistry._raw_homepage_options.dup
example.run
DiscoursePluginRegistry._raw_homepage_options.replace(registrations)
end
it "offers the top_menu fallback and every homepage choice" do
values = described_class.values
@@ -18,6 +24,25 @@ describe HomepageSiteSetting do
)
end
it "includes homepages registered by enabled plugins" do
plugin = Plugin::Instance.new
plugin.stubs(:enabled?).returns(true)
plugin.register_homepage(
"directory",
name: "discourse_directory.navigation.title",
path: "/directory",
route: "discourse_directory/directory#index",
anonymous: true,
)
expect(described_class.values).to include(
{ name: "discourse_directory.navigation.title", value: "directory" },
)
plugin.stubs(:enabled?).returns(false)
expect(described_class.values.map { |value| value[:value] }).not_to include("directory")
end
it "does not offer unread when it is excluded from top menu choices" do
TopMenu.stubs(:choices).returns(%w[latest new top categories])
+43
View File
@@ -96,6 +96,12 @@ RSpec.describe SiteSetting do
end
describe "homepage" do
around do |example|
registrations = DiscoursePluginRegistry._raw_homepage_options.dup
example.run
DiscoursePluginRegistry._raw_homepage_options.replace(registrations)
end
it "uses default_homepage when set" do
SiteSetting.default_homepage = "bookmarks"
expect(SiteSetting.homepage).to eq("bookmarks")
@@ -128,6 +134,43 @@ RSpec.describe SiteSetting do
SiteSetting.enable_unified_new = true
expect(SiteSetting.homepage).to eq("categories")
end
it "uses a registered plugin homepage and falls back when the plugin is disabled" do
plugin = Plugin::Instance.new
plugin.stubs(:enabled?).returns(true)
plugin.register_homepage(
"directory",
name: "discourse_directory.navigation.title",
path: "/directory",
route: "discourse_directory/directory#index",
anonymous: true,
)
SiteSetting.top_menu = "categories|latest"
SiteSetting.default_homepage = "directory"
expect(SiteSetting.homepage).to eq("directory")
expect(SiteSetting.anonymous_homepage).to eq("directory")
plugin.stubs(:enabled?).returns(false)
expect(SiteSetting.homepage).to eq("categories")
expect(SiteSetting.anonymous_homepage).to eq("categories")
end
it "does not use a private plugin homepage for anonymous visitors" do
plugin = Plugin::Instance.new
plugin.stubs(:enabled?).returns(true)
plugin.register_homepage(
"private_page",
name: "plugin.private_page",
path: "/private-page",
route: "plugin/private_page#index",
)
SiteSetting.top_menu = "categories|latest"
SiteSetting.default_homepage = "private_page"
expect(SiteSetting.homepage).to eq("private_page")
expect(SiteSetting.anonymous_homepage).to eq("categories")
end
end
end
@@ -340,6 +340,28 @@
"type": "array",
"items": {}
},
"homepage_options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"path": {
"type": "string"
},
"server_side": {
"type": "boolean"
}
},
"required": [
"id",
"path",
"server_side"
]
}
},
"periods": {
"type": "array",
"items": {}
@@ -1038,6 +1060,7 @@
"filters",
"anonymous_list_filters",
"homepage_choices",
"homepage_options",
"periods",
"top_menu_items",
"anonymous_top_menu_items",
@@ -1067,4 +1090,4 @@
"full_name_visible_in_signup",
"email_configured"
]
}
}
+27 -1
View File
@@ -17,9 +17,35 @@ RSpec.describe SiteSerializer do
end
describe "#homepage_choices" do
around do |example|
registrations = DiscoursePluginRegistry._raw_homepage_options.dup
example.run
DiscoursePluginRegistry._raw_homepage_options.replace(registrations)
end
it "exposes the eligible homepage choices" do
serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json
expect(serialized[:homepage_choices]).to eq(TopMenu.homepage_choices)
expect(serialized[:homepage_choices]).to eq(HomepageSiteSetting.choices)
end
it "exposes registered homepage paths" do
plugin = Plugin::Instance.new
plugin.stubs(:enabled?).returns(true)
plugin.register_homepage(
"directory",
name: "discourse_directory.navigation.title",
path: "/directory",
route: "discourse_directory/directory#index",
)
serialized = described_class.new(Site.new(guardian), scope: guardian, root: false).as_json
expect(serialized[:homepage_choices]).to include("directory")
expect(serialized[:homepage_options]).to include(
id: "directory",
path: "/directory",
server_side: false,
)
end
end
+129
View File
@@ -0,0 +1,129 @@
# frozen_string_literal: true
RSpec.describe "Plugin homepages" do
before do
Object.const_set(
:PluginHomepageSystemSpecController,
Class.new(ApplicationController) do
layout "no_ember"
skip_before_action :check_xhr, :preload_json
def public_page
render html: '<main id="plugin-public-homepage">Public plugin homepage</main>'.html_safe
end
def private_page
render html: '<main id="plugin-private-homepage">Private plugin homepage</main>'.html_safe
end
def alternate_page
render html:
'<main id="plugin-alternate-homepage">Alternate plugin homepage</main>'.html_safe
end
end,
)
@plugins = []
end
after do
DiscoursePluginRegistry._raw_homepage_options.reject! do |registration|
@plugins.include?(registration[:plugin])
end
Rails.application.reload_routes!
Site.clear_cache
Object.send(:remove_const, :PluginHomepageSystemSpecController)
end
it "serves an anonymous plugin homepage directly at the root" do
register_homepage(
"public-plugin",
route: "plugin_homepage_system_spec#public_page",
anonymous: true,
server_side: true,
)
select_homepage("public-plugin")
visit "/"
expect(page).to have_current_path("/")
expect(page).to have_css("#plugin-public-homepage", text: "Public plugin homepage")
end
it "falls back for anonymous visitors but serves a private homepage to members" do
register_homepage(
"private-plugin",
route: "plugin_homepage_system_spec#private_page",
server_side: true,
)
select_homepage("private-plugin")
visit "/"
expect(page).to have_css("#list-area")
expect(page).not_to have_css("#plugin-private-homepage")
sign_in Fabricate(:user)
visit "/"
expect(page).to have_css("#plugin-private-homepage", text: "Private plugin homepage")
end
it "supports multiple registrations and falls back when the selected plugin is disabled" do
register_homepage(
"sample-homepage",
route: "plugin_homepage_system_spec#public_page",
anonymous: true,
server_side: true,
)
selected_plugin =
register_homepage(
"sample_homepage",
route: "plugin_homepage_system_spec#alternate_page",
anonymous: true,
server_side: true,
)
select_homepage("sample_homepage")
visit "/"
expect(page).to have_css("#plugin-alternate-homepage", text: "Alternate plugin homepage")
selected_plugin.enabled = false
Site.clear_cache
visit "/"
expect(page).to have_css("#list-area")
expect(page).not_to have_css("#plugin-alternate-homepage")
end
private
def register_homepage(id, route:, anonymous: false, server_side: false)
plugin = plugin_class.new
plugin.enabled = true
plugin.register_homepage(
id,
name: "plugin.#{id}",
path: "/#{id}",
route:,
anonymous:,
server_side:,
)
@plugins << plugin
plugin
end
def select_homepage(id)
SiteSetting.default_homepage = id
Rails.application.reload_routes!
Site.clear_cache
end
def plugin_class
@plugin_class ||=
Class.new(Plugin::Instance) do
attr_accessor :enabled
def enabled?
enabled
end
end
end
end