2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-03 23:54:20 +08:00
discourse/spec/system/blocks_spec.rb
Sérgio Saquetim 9dcc851c9b
DEV: Add Block API for declarative, validated UI extension points (#36810)
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>
2026-02-25 17:36:43 -03:00

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