mirror of
https://github.com/discourse/discourse.git
synced 2026-03-03 23:54:20 +08:00
This commit introduces the Blocks API, a declarative system for composing
validated, conditionally rendered UI layouts.
Blocks are Glimmer components with typed argument schemas, conditional
rendering rules, and outlet restrictions — all validated at registration
time. The system provides centralized registries for blocks, outlets,
and condition types, with a two-phase lifecycle that freezes registries
after initialization. Container blocks enable hierarchical layouts, and
a built-in condition system supports route matching, user state, site
settings, viewport breakpoints, and outlet arg inspection — all
composable with AND/OR/NOT combinators.
## Plugin API
Four methods are added to the plugin API.
`api.registerBlock(BlockClass)` registers a block component for use in
layouts, supporting both direct class registration and lazy loading via
factory functions (`api.registerBlock("name", () => import(...))`).
`api.renderBlocks(outletName, layout)` defines the block layout for an
outlet, where each entry is a `LayoutEntry` describing the block, its
args, class names, children, conditions, and container args.
`api.registerBlockOutlet(name, options)` allows plugins and themes to
define their own named outlets.
`api.registerBlockConditionType(ConditionClass)` registers a custom
condition type.
Registration methods must be called in pre-initializers (before the
registry freeze), while `renderBlocks` must be called in
api-initializers (after the freeze).
## The `@block` Decorator
The `@block(name, options)` decorator transforms a Glimmer component
into a block. It supports typed argument schemas with validation
constraints (`min`, `max`, `minLength`, `maxLength`, `pattern`, `enum`,
`itemEnum`, `integer`) for types `string`, `number`, `boolean`, `array`,
`object`, and `any`. Cross-argument constraints like `atLeastOne`,
`exactlyOne`, `allOrNone`, `atMostOne`, and `requires` enforce
relationships between arguments. A `validate(args)` function enables
custom validation logic.
Blocks can restrict which outlets they render in using `allowedOutlets`
and `deniedOutlets` with glob patterns.
Container blocks (`container: true`) can nest children and define a
`childArgs` schema for args passed from children to the parent.
Namespacing is enforced: core blocks use `block-name`, plugins use
`plugin-name:block-name`, and themes use `theme:theme-name:block-name`.
Blocks can only render inside `<BlockOutlet>` components or as
authorized children of container blocks — direct template usage throws
an error.
## `<BlockOutlet>` Component
`<BlockOutlet>` is the root rendering component and is itself a
container block. It accepts `@name` (the outlet identifier),
`@outletArgs` (arguments passed to all blocks in the outlet), and
`@deprecatedArgs` (args that trigger deprecation warnings on access). It
provides `<:before>`, `<:after>`, and `<:error>` named block slots.
Four core outlets are defined: `hero-blocks`, `homepage-blocks`,
`main-outlet-blocks`, and `sidebar-blocks`. The application template
places `<BlockOutlet>` components for `hero-blocks` and
`main-outlet-blocks`, both hidden on admin routes.
## Conditions
Five built-in condition types handle the most common rendering
scenarios.
The `route` condition matches URL patterns using glob syntax, semantic
page types (`CATEGORY_PAGES`, `TAG_PAGES`, `DISCOVERY_PAGES`,
`HOMEPAGE`, `TOPIC_PAGES`, `USER_PAGES`, `ADMIN_PAGES`, `GROUP_PAGES`,
`TOP_MENU`), route params, and query params.
The `user` condition checks login status, trust level,
admin/moderator/staff status, and group membership.
The `setting` condition evaluates site settings with operators like
`enabled`, `equals`, `includes`, `contains`, and `containsAny`.
The `viewport` condition matches breakpoints (`sm`, `md`, `lg`, `xl`,
`2xl`) and touch capability.
The `outlet-arg` condition matches outlet argument values using
dot-notation paths with value matchers supporting primitives, arrays,
regex, `not`, and `any`.
Conditions are composable through combinators: arrays represent AND
logic, `{ any: [...] }` represents OR logic, and `{ not: {...} }`
represents NOT logic. Parameters within conditions (`params`,
`queryParams`) support the same combinator syntax.
## The `@blockCondition` Decorator
Custom conditions extend `BlockCondition` and use the
`@blockCondition(config)` decorator. The config accepts a `type` name, a
`sourceType` for resolving external data (`"none"`, `"outletArgs"`, or
`"object"` for sources like theme settings), an argument schema with the
same validation as block args, cross-arg constraints, and a custom
validation function.
## Built-in Container Blocks
Two container blocks are provided. `group` renders all its children in
sequence, acting as a simple grouping mechanism. `head` renders only the
first child whose conditions pass, implementing an if/else-if/else
fallback pattern.
## Two-Phase Initialization
Registration happens in pre-initializers. The `freeze-block-registry`
initializer then registers built-in blocks and core condition types, and
freezes all three registries (blocks, outlets, conditions) to prevent
further modifications. Layout rendering via `api.renderBlocks()` happens
in api-initializers after the freeze, ensuring all blocks and conditions
are available when layouts are validated.
## Developer Tooling
The dev tools integration provides a visual overlay that renders block
boundaries with hover tooltips showing the block name, conditions, and
arguments. Ghost blocks appear as dashed placeholders for hidden blocks,
showing why they're hidden (failed conditions, no visible children, or
hidden by `head`). Console logging outputs hierarchical condition
evaluation with pass/fail icons, color coding, resolved values, and type
mismatch hints. Outlet info badges display the outlet name, block count,
and arguments.
## Testing Utilities
`withTestBlockRegistration(callback)` and
`withTestConditionRegistration(callback)` temporarily unfreeze their
respective registries for test registration. Block query helpers
(`hasBlock`, `getBlockEntry`, `resolveBlock`, `tryResolveBlock`,
`isBlockResolved`) enable assertions on registry state.
`validateConditions(spec)` validates condition specs in tests.
`setupGhostCapture()` captures ghost block data for assertions on which
blocks were skipped and why.
## Test Coverage
Unit tests cover all five condition types, argument validation,
constraint validation, layout validation, the block/outlet/condition
registries, string similarity, outlet args, value matching, URL
matching, page definitions, debug logging, and console formatting.
Integration tests cover `<BlockOutlet>` rendering, container behavior
(`group`, `head`), condition evaluation, and layout wrappers. System
tests validate conditional rendering across SPA navigation and the dev
tools overlay.
---------
Co-authored-by: David Taylor <david@taylorhq.com>
283 lines
8.1 KiB
Ruby
283 lines
8.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
describe "Block conditions", type: :system do
|
|
fab!(:theme) do
|
|
theme_dir = "#{Rails.root}/spec/fixtures/themes/dev-tools-test-theme"
|
|
theme = RemoteTheme.import_theme_from_directory(theme_dir)
|
|
Theme.find(SiteSetting.default_theme_id).child_themes << theme
|
|
theme
|
|
end
|
|
|
|
fab!(:admin)
|
|
fab!(:moderator)
|
|
fab!(:trust_level_2_user) { Fabricate(:user, trust_level: TrustLevel[2]) }
|
|
fab!(:trust_level_1_user) { Fabricate(:user, trust_level: TrustLevel[1]) }
|
|
fab!(:trust_level_0_user) { Fabricate(:user, trust_level: TrustLevel[0]) }
|
|
fab!(:category)
|
|
fab!(:post)
|
|
fab!(:topic) { post.topic }
|
|
|
|
let(:blocks) { PageObjects::Components::Blocks.new }
|
|
|
|
describe "user conditions" do
|
|
context "when anonymous" do
|
|
it "hides logged-in-only blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("user-logged-in")
|
|
expect(blocks).to have_no_block("user-admin")
|
|
expect(blocks).to have_no_block("user-moderator")
|
|
expect(blocks).to have_no_block("user-trust-level-2")
|
|
end
|
|
end
|
|
|
|
context "when logged in as regular user (TL0)" do
|
|
before { sign_in(trust_level_0_user) }
|
|
|
|
it "shows logged-in blocks but not role-specific blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("user-logged-in")
|
|
expect(blocks).to have_no_block("user-admin")
|
|
expect(blocks).to have_no_block("user-moderator")
|
|
expect(blocks).to have_no_block("user-trust-level-2")
|
|
end
|
|
end
|
|
|
|
context "when logged in as TL2 user" do
|
|
before { sign_in(trust_level_2_user) }
|
|
|
|
it "shows trust level blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("user-logged-in")
|
|
expect(blocks).to have_block("user-trust-level-2")
|
|
expect(blocks).to have_no_block("user-admin")
|
|
end
|
|
end
|
|
|
|
context "when logged in as moderator" do
|
|
before { sign_in(moderator) }
|
|
|
|
it "shows moderator blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("user-logged-in")
|
|
expect(blocks).to have_block("user-moderator")
|
|
expect(blocks).to have_no_block("user-admin")
|
|
end
|
|
end
|
|
|
|
context "when logged in as admin" do
|
|
before { sign_in(admin) }
|
|
|
|
it "shows admin blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("user-logged-in")
|
|
expect(blocks).to have_block("user-admin")
|
|
# Admins are also moderators
|
|
expect(blocks).to have_block("user-moderator")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "route conditions" do
|
|
before { sign_in(trust_level_2_user) }
|
|
|
|
it "shows discovery blocks on discovery pages" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("route-discovery")
|
|
expect(blocks).to have_no_block("route-category")
|
|
expect(blocks).to have_no_block("route-topic")
|
|
end
|
|
|
|
it "shows category blocks on category pages" do
|
|
visit("/c/#{category.slug}/#{category.id}")
|
|
|
|
expect(blocks).to have_block("route-discovery")
|
|
expect(blocks).to have_block("route-category")
|
|
expect(blocks).to have_no_block("route-topic")
|
|
end
|
|
|
|
it "shows topic blocks on topic pages" do
|
|
visit("/t/#{topic.slug}/#{topic.id}")
|
|
|
|
expect(blocks).to have_block("route-topic")
|
|
expect(blocks).to have_no_block("route-discovery")
|
|
expect(blocks).to have_no_block("route-category")
|
|
end
|
|
end
|
|
|
|
describe "route navigation (SPA)" do
|
|
before { sign_in(trust_level_2_user) }
|
|
|
|
it "re-evaluates blocks when navigating from discovery to topic via click" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("route-discovery")
|
|
expect(blocks).to have_no_block("route-topic")
|
|
|
|
find(".topic-list-item .raw-topic-link[data-topic-id='#{topic.id}']").click
|
|
|
|
expect(blocks).to have_block("route-topic")
|
|
expect(blocks).to have_no_block("route-discovery")
|
|
end
|
|
|
|
it "re-evaluates blocks when navigating from topic back to discovery" do
|
|
visit("/t/#{topic.slug}/#{topic.id}")
|
|
|
|
expect(blocks).to have_block("route-topic")
|
|
expect(blocks).to have_no_block("route-discovery")
|
|
|
|
find("#site-logo").click
|
|
|
|
expect(blocks).to have_block("route-discovery")
|
|
expect(blocks).to have_no_block("route-topic")
|
|
end
|
|
|
|
it "re-evaluates combined conditions when navigating to category page" do
|
|
sign_in(admin)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("combined-admin-category")
|
|
|
|
find(".sidebar-section-link", text: category.name).click
|
|
|
|
expect(blocks).to have_block("combined-admin-category")
|
|
end
|
|
end
|
|
|
|
describe "setting conditions" do
|
|
before { sign_in(trust_level_2_user) }
|
|
|
|
context "when enable_badges is true" do
|
|
before { SiteSetting.enable_badges = true }
|
|
|
|
it "shows setting-dependent block" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("setting-badges-enabled")
|
|
end
|
|
end
|
|
|
|
context "when enable_badges is false" do
|
|
before { SiteSetting.enable_badges = false }
|
|
|
|
it "hides setting-dependent block" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("setting-badges-enabled")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "combined conditions (AND logic)" do
|
|
context "with logged-in + TL1 requirement" do
|
|
it "hides block when not logged in" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("combined-logged-in-tl1")
|
|
end
|
|
|
|
it "hides block when logged in but below TL1" do
|
|
sign_in(trust_level_0_user)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("combined-logged-in-tl1")
|
|
end
|
|
|
|
it "shows block when logged in and at TL1+" do
|
|
sign_in(trust_level_1_user)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("combined-logged-in-tl1")
|
|
end
|
|
end
|
|
|
|
context "with admin + category route requirement" do
|
|
it "hides block when admin but not on category page" do
|
|
sign_in(admin)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("combined-admin-category")
|
|
end
|
|
|
|
it "hides block when on category page but not admin" do
|
|
sign_in(trust_level_2_user)
|
|
visit("/c/#{category.slug}/#{category.id}")
|
|
|
|
expect(blocks).to have_no_block("combined-admin-category")
|
|
end
|
|
|
|
it "shows block when admin AND on category page" do
|
|
sign_in(admin)
|
|
visit("/c/#{category.slug}/#{category.id}")
|
|
|
|
expect(blocks).to have_block("combined-admin-category")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "OR conditions (any combinator)" do
|
|
it "hides block when neither admin nor moderator" do
|
|
sign_in(trust_level_2_user)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_no_block("or-admin-or-moderator")
|
|
end
|
|
|
|
it "shows block when moderator (not admin)" do
|
|
sign_in(moderator)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("or-admin-or-moderator")
|
|
end
|
|
|
|
it "shows block when admin (not just moderator)" do
|
|
sign_in(admin)
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("or-admin-or-moderator")
|
|
end
|
|
end
|
|
|
|
describe "block ordering" do
|
|
before { sign_in(trust_level_2_user) }
|
|
|
|
it "renders blocks in the order they were configured" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("order-first")
|
|
expect(blocks).to have_block("order-second")
|
|
expect(blocks).to have_block("order-third")
|
|
expect(blocks).to have_block("order-fourth")
|
|
expect(blocks).to have_block("order-fifth")
|
|
|
|
expect(blocks.has_blocks_in_order?([1, 2, 3, 4, 5])).to be true
|
|
end
|
|
end
|
|
|
|
describe "viewport conditions" do
|
|
before { sign_in(trust_level_2_user) }
|
|
|
|
context "when on desktop viewport (default)" do
|
|
it "shows desktop-only blocks and hides mobile-only blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("viewport-desktop")
|
|
expect(blocks).to have_no_block("viewport-mobile")
|
|
end
|
|
end
|
|
|
|
context "when on mobile viewport", mobile: true do
|
|
it "shows mobile-only blocks and hides desktop-only blocks" do
|
|
visit("/latest")
|
|
|
|
expect(blocks).to have_block("viewport-mobile")
|
|
expect(blocks).to have_no_block("viewport-desktop")
|
|
end
|
|
end
|
|
end
|
|
end
|