2
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-03-04 01:15:08 +08:00
discourse/spec/system/dev_tools_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

192 lines
7.3 KiB
Ruby

# frozen_string_literal: true
describe "Discourse dev tools", type: :system do
let(:toolbar) { PageObjects::Components::DevTools::Toolbar.new }
let(:plugin_outlet_debug) { PageObjects::Components::DevTools::PluginOutletDebug.new }
let(:block_debug) { PageObjects::Components::DevTools::BlockDebug.new }
describe "toolbar" do
it "can be enabled and disabled" do
visit("/latest")
expect(page).to have_css("#site-logo")
expect(toolbar).to have_no_toolbar
toolbar.enable
expect(toolbar).to have_toolbar
toolbar.disable
expect(toolbar).to have_no_toolbar
expect(page).to have_css("#site-logo")
end
end
describe "plugin outlet debugging" do
it "shows plugin outlet overlays with tooltips" do
visit("/latest")
toolbar.enable
toolbar.toggle_plugin_outlets
expect(plugin_outlet_debug).to have_outlets(minimum: 10)
plugin_outlet_debug.hover_outlet("home-logo-contents__before")
expect(plugin_outlet_debug).to have_tooltip
expect(plugin_outlet_debug).to have_arg(key: "@title")
expect(plugin_outlet_debug).to have_arg_value(value: "\"#{SiteSetting.title}\"")
expect(plugin_outlet_debug).to have_github_link
toolbar.toggle_plugin_outlets
expect(plugin_outlet_debug).to have_no_outlets
end
it "shows wrapper outlet indicator" do
visit("/latest")
toolbar.enable
toolbar.toggle_plugin_outlets
expect(plugin_outlet_debug).to have_wrapper_outlet
end
end
describe "block debugging" do
it "shows block outlet boundaries with tooltip" do
visit("/latest")
toolbar.enable
toolbar.toggle_block_outlet_boundaries
expect(block_debug).to have_outlet_boundary
block_debug.hover_outlet_badge
expect(block_debug).to have_outlet_tooltip
expect(block_debug).to have_outlet_github_link
end
context "with test theme blocks" 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
it "shows block visual overlay with tooltip" do
visit("/latest")
toolbar.enable
toolbar.toggle_block_visual_overlay
expect(block_debug).to have_block_info("theme:dev-tools-test:dev-tools-test-block")
block_debug.hover_block_badge("theme:dev-tools-test:dev-tools-test-block")
expect(block_debug).to have_block_tooltip
expect(block_debug).to have_block_title("dev-tools-test-block")
expect(block_debug).to have_block_location("hero-blocks")
expect(block_debug).to have_block_arg(key: "title")
end
it "shows correct block count in outlet boundary tooltip" do
visit("/latest")
toolbar.enable
toolbar.toggle_block_outlet_boundaries
block_debug.hover_outlet_badge("hero-blocks")
expect(block_debug).to have_outlet_tooltip
expect(block_debug).to have_outlet_block_count(23)
end
it "shows ghost blocks for failed conditions" do
visit("/latest") # Anonymous user, admin condition fails
toolbar.enable
toolbar.toggle_ghost_blocks
expect(block_debug).to have_ghost_block("theme:dev-tools-test:dev-tools-conditional-block")
block_debug.hover_ghost_badge("theme:dev-tools-test:dev-tools-conditional-block")
expect(block_debug).to have_ghost_tooltip
expect(block_debug).to have_conditions
end
it "shows conditions as failed with condition type" do
visit("/latest") # Anonymous user, admin condition fails
toolbar.enable
toolbar.toggle_ghost_blocks
block_debug.hover_ghost_badge("theme:dev-tools-test:dev-tools-conditional-block")
expect(block_debug).to have_failed_conditions
expect(block_debug).to have_condition_type("user")
end
it "shows multiple condition types for combined conditions" do
visit("/latest") # Anonymous user, admin + TL2 condition fails
toolbar.enable
toolbar.toggle_ghost_blocks
block_debug.hover_ghost_badge("theme:dev-tools-test:debug-conditions-block")
expect(block_debug).to have_failed_conditions
expect(block_debug).to have_condition_type("AND")
expect(block_debug).to have_condition_type("user")
end
it "shows block args values in tooltip" do
visit("/latest")
toolbar.enable
toolbar.toggle_block_visual_overlay
expect(block_debug).to have_block_info("theme:dev-tools-test:debug-args-block")
block_debug.hover_block_badge("theme:dev-tools-test:debug-args-block")
expect(block_debug).to have_block_tooltip
expect(block_debug).to have_block_arg(key: "title")
expect(block_debug).to have_block_arg(key: "count")
expect(block_debug).to have_block_arg(key: "enabled")
end
it "shows ghost blocks with combined conditions" do
visit("/latest") # Anonymous user, admin + TL2 condition fails
toolbar.enable
toolbar.toggle_ghost_blocks
expect(block_debug).to have_ghost_block("theme:dev-tools-test:debug-conditions-block")
block_debug.hover_ghost_badge("theme:dev-tools-test:debug-conditions-block")
expect(block_debug).to have_ghost_tooltip
expect(block_debug).to have_conditions
end
it "shows nested ghost blocks for groups with all children hidden (4 levels deep)" do
visit("/latest") # Anonymous user, all nested children fail admin condition
toolbar.enable
toolbar.toggle_ghost_blocks
# The outermost group should appear as a ghost since all its children are hidden
expect(block_debug).to have_ghost_block("group")
# Verify all 4 levels of nested groups appear as ghosts
expect(page).to have_css(".block-debug-ghost[data-block-name='group']", minimum: 4)
# The leaf block should also appear as a ghost
expect(block_debug).to have_ghost_block("theme:dev-tools-test:nested-ghost-leaf-block")
end
it "reactively shows and hides overlays when toggling without page refresh" do
visit("/latest")
toolbar.enable
# Initially no overlays
expect(block_debug).to have_no_block_info
expect(block_debug).to have_no_ghost_block
# Enable visual overlay and ghost blocks - should appear without page refresh
toolbar.toggle_block_visual_overlay
toolbar.toggle_ghost_blocks
expect(block_debug).to have_block_info("theme:dev-tools-test:dev-tools-test-block")
expect(block_debug).to have_ghost_block("theme:dev-tools-test:dev-tools-conditional-block")
# Disable both - should disappear without page refresh
toolbar.toggle_block_visual_overlay
toolbar.toggle_ghost_blocks
expect(block_debug).to have_no_block_info
expect(block_debug).to have_no_ghost_block
# Re-enable to confirm reactivity works both ways
toolbar.toggle_block_visual_overlay
toolbar.toggle_ghost_blocks
expect(block_debug).to have_block_info("theme:dev-tools-test:dev-tools-test-block")
expect(block_debug).to have_ghost_block("theme:dev-tools-test:dev-tools-conditional-block")
end
end
end
end