diff --git a/docs/11-proposals/README.md b/docs/11-proposals/README.md index e335e08b7..b57da6b82 100644 --- a/docs/11-proposals/README.md +++ b/docs/11-proposals/README.md @@ -29,7 +29,7 @@ for display in this README): ## Active Proposals (84) -### In Progress (partial) (8) +### In Progress (partial) (9) | Proposal | Status | Summary | |----------|--------|---------| @@ -41,8 +41,9 @@ for display in this README): | [mxcli marketplace — Download & Manage Marketplace Modules](PROPOSAL_marketplace_modules.md) | Partial | Unblocking path #1 below has happened: the content API now returns a | | [Navigation Support in MDL](navigation-support.md) | Partial | Every Mendix project has exactly one navigation$NavigationDocument — a project-level singleton containing navigation profiles (Responsive, P | | [Podman Support as Docker Alternative](PROPOSAL_podman_support.md) | Partial | Docker Desktop requires a paid subscription for larger organizations. | +| [SHOW/DESCRIBE/USE Building Blocks](show-describe-building-blocks.md) | Partial | Document type: Pages$BuildingBlock (NOT Forms$BuildingBlock — the reader now | -### Proposed (36) +### Proposed (35) | Proposal | Status | Summary | |----------|--------|---------| @@ -60,7 +61,6 @@ for display in this README): | [Page Composition and Partial Updates](proposal_page_composition.md) | Proposed | Large MDL page scripts become unwieldy to write, read, and maintain. | | [Prompt: Journey Architecture Diagram Generator for Mendix Projects](journey-architecture-viz.md) | Proposed | I'm building a CLI tool (part of mxcli) that generates a "Customer Journey Architecture" diagram from a Mendix project. | | [Session Logging & Diagnostics for mxcli](PROPOSAL_session_logging.md) | Proposed | mxcli is being distributed to users for testing. | -| [SHOW/DESCRIBE Building Blocks](show-describe-building-blocks.md) | Proposed | Document type: Forms$BuildingBlock | | [SHOW/DESCRIBE Custom Icon Collections](show-describe-custom-icon-collections.md) | Proposed | Document type: CustomIcons$CustomIconCollection | | [SHOW/DESCRIBE Export Mappings](show-describe-export-mappings.md) | Proposed | Document type: ExportMappings$ExportMapping | | [SHOW/DESCRIBE Image Collections](show-describe-image-collections.md) | Proposed | Document type: Images$ImageCollection | diff --git a/docs/11-proposals/show-describe-building-blocks.md b/docs/11-proposals/show-describe-building-blocks.md index 360fd2622..a6f45eb2d 100644 --- a/docs/11-proposals/show-describe-building-blocks.md +++ b/docs/11-proposals/show-describe-building-blocks.md @@ -1,18 +1,33 @@ --- -title: SHOW/DESCRIBE Building Blocks -status: proposed +title: SHOW/DESCRIBE/USE Building Blocks +status: partial +related: + - PR #16 (READ — SHOW/DESCRIBE + catalog) + - PR #17 (READ — modelsdk ListBuildingBlocks + exec-safe example) --- -# Proposal: SHOW/DESCRIBE Building Blocks +# Proposal: SHOW / DESCRIBE / USE Building Blocks ## Overview -**Document type:** `Forms$BuildingBlock` -**Prevalence:** 233 across test projects (83 Enquiries, 73 Evora, 77 Lato) +**Document type:** `Pages$BuildingBlock` (NOT `Forms$BuildingBlock` — the reader now +does a `Pages$`-first, `Forms$`-fallback lookup) +**Prevalence:** every project ships ~39 out-of-the-box Atlas blocks (`Atlas_Web_Content`) **Priority:** High — present in every project, reusable UI components Building Blocks are reusable widget compositions that can be dragged onto pages in Studio Pro. They are structurally similar to Snippets but serve as templates rather than runtime components. +The Building-Block capability has three layers — **READ → INSTANTIATE → AUTHOR**: + +| Capability | What | Status | +|---|---|---| +| **READ** — `SHOW`/`DESCRIBE BUILDING BLOCK`, `CATALOG.building_blocks` | discover + inspect a block's widget tree | ✅ **shipped** (PR #16/#17); validated live against a real Atlas app (40 blocks) | +| **INSTANTIATE** — `USE BUILDING BLOCK` | deep-copy a block onto a page | 📐 **specced below** (v1 in progress) | +| **AUTHOR** — `CREATE BUILDING BLOCK` | contribute a block back to the toolbox | ⬜ future | + +The rest of this document: the (now-shipped) READ design for reference, then the +**USE BUILDING BLOCK follow-up slice**. + ## What Already Exists | Layer | Status | Location | @@ -131,3 +146,110 @@ func (e *Executor) GetBuildingBlockNames(moduleFilter string) []string - Create `mdl-examples/doctype-tests/17-building-block-examples.mdl` - Verify against all 3 test projects + +--- + +# Follow-up slice: `USE BUILDING BLOCK` (Instantiate) + +The READ capability lets you *discover and inspect* a project's out-of-the-box Atlas +blocks. `USE BUILDING BLOCK` closes the loop: **deep-copy** a block's widget tree onto +a page in one line, instead of hand-mirroring its `DESCRIBE` output. This is the single +biggest lever for the `atlas-design` skill — it turns "discover → inspect → **mirror**" +into "discover → **use**". + +## Syntax — mirrors `use fragment`, sourced from a persisted block + +```mdl +use building block Atlas_Web_Content.Card -- deep-copy the block's tree here +use building block Atlas_Web_Content.Card as cust_ -- prefix the copied widget names +``` + +It is a **page-body element** (valid anywhere `use fragment` is — page bodies, containers, +columns, placeholders), not a top-level statement. The only syntactic difference from +`use fragment X as p_` is the **qualified name** (`Module.Name`), because a building block +is a persisted `Pages$BuildingBlock` document rather than a script-scoped `define fragment`. + +### Worked example + +```mdl +create page Sales.CustomerOverview (title: 'Customers') { + layoutgrid g1 { + row r1 { + column c1 (desktopwidth: 6) { use building block Atlas_Web_Content.Card as cust_ } + column c2 (desktopwidth: 6) { use building block Atlas_Web_Content.Card as order_ } + } + } +} +``` + +Each `use` deep-copies the block's real tree (from `DESCRIBE Atlas_Web_Content.Card`): +`container (DesignProperties: ['Card style': on]) { dynamictext (Class: 'card-title', …) }` +→ `as cust_` yields `cust_container…` + `cust_text…`, `as order_` the parallel set. + +## Semantics (match how Atlas blocks actually behave) + +- **Deep copy, no live link** — identical to dragging a block onto a page in Studio Pro. + After insertion the widgets are the page's own, freely editable. +- **Name-collision handling** via the `as ` rename (same as fragments). +- **Read-only source** — the block document is never mutated; `USE` only reads it. + +## Configuration: afterwards (v1), inline sugar (v1.1) — never magic + +A building block has **no parameter interface** — it is a raw widget-tree template, so +there is nothing to pass arguments *to*. You configure by editing the copied widgets. + +- **v1 — configure afterwards** with the already-shipped `alter page` commands. This is + the honest default (mirrors Studio Pro's drag-then-configure) and needs zero new + machinery beyond the copy: + ```mdl + use building block Atlas_Web_Content.Card as cust_ + alter page Sales.CustomerOverview set cust_text22 (content: 'Customers'); + ``` +- **v1.1 — optional inline override block** (sugar over `use` + immediate `alter`), scoped + to the freshly-copied widgets, referencing the block's real internal names (from + `DESCRIBE`): + ```mdl + use building block Atlas_Web_Content.Card as cust_ { + set text22 (content: 'Customers') + } + ``` +- **Maybe — a data-context shortcut** for the one config a block often can't work without + (a `Master_Detail`/`List_Cards` dropped with no entity is inert): + `use building block Atlas_Web_Content.List_Cards as prod_ over database Sales.Product` + — this just resolves to "set the copied datasource host's source", not a slot. +- **Never — implicit slots.** Do not have the engine guess which widget is "the title" or + "the datasource host". Atlas blocks don't declare roles; heuristic inference is fragile. + Keep configuration **explicit** (by widget name), inline or post-hoc. + +## Implementation (v1) — reuse the fragment-expansion machinery + +Confirmed feasible; v1 in progress on `feature/use-building-block`. + +1. **Grammar** — add `useBuildingBlockRef : USE BUILDING BLOCK qualifiedName (AS + identifierOrKeyword)?` to `MDLPage.g4`, as an alternative everywhere `useFragmentRef` + appears in the page body. (`BUILDING`/`BLOCK` tokens already exist from READ.) +2. **AST/visitor** — mirror `buildUseFragmentRef`: emit a sentinel + `WidgetV3{Type: "USE_BUILDING_BLOCK", Name: "", Properties: {Prefix}}`. +3. **Executor** — in `expandIfFragment` (the same hook that expands `USE_FRAGMENT`), add a + branch that resolves the block (`ListBuildingBlocks` + hierarchy), reads its widgets via + the READ path (`getBuildingBlockWidgetsFromRaw` → `GetRawUnit`), and converts them to + `[]*ast.WidgetV3`, then applies the prefix and returns them for the normal builder to + serialize. +4. **BSON → `[]*ast.WidgetV3` conversion** — rather than a fragile hand-written converter, + **round-trip through the DESCRIBE renderer**: render the block's raw widgets to MDL + (`outputWidgetMDLV3`), wrap in `define fragment __tmp as { … }`, re-parse with + `visitor.Build`, and take `.Widgets`. This reuses the entire existing widget + parser/visitor, so it handles every widget type DESCRIBE can emit and degrades to a + clear parse error otherwise. (Verified: real Card DESCRIBE output re-parses cleanly.) + +## Testing + +- Executor mock test: a block's raw widgets → expanded (prefixed) into a page's widget list. +- Real end-to-end: `use building block Atlas_Web_Content.Card as cust_` on a real Atlas + project, then `describe page` to confirm the prefixed card widgets landed, plus `mx check`. + +## Later + +- **v1.1** inline override block; the `over database …` data-context shortcut. +- **AUTHOR** (`CREATE BUILDING BLOCK`) so generated apps contribute blocks back to the + Studio-Pro toolbox. diff --git a/mdl/executor/cmd_alter_page.go b/mdl/executor/cmd_alter_page.go index 2dd42870b..cb69c3acc 100644 --- a/mdl/executor/cmd_alter_page.go +++ b/mdl/executor/cmd_alter_page.go @@ -283,6 +283,7 @@ func buildColumnSpecsFromAST(ctx *ExecContext, widgets []*ast.WidgetV3, moduleNa } pb := &pageBuilder{ + ctx: ctx, backend: ctx.Backend, moduleID: moduleID, moduleName: moduleName, @@ -323,6 +324,7 @@ func buildWidgetsFromAST(ctx *ExecContext, widgets []*ast.WidgetV3, moduleName s } pb := &pageBuilder{ + ctx: ctx, backend: ctx.Backend, moduleID: moduleID, moduleName: moduleName, diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index d8548b120..1a440f8b5 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -24,6 +24,7 @@ import ( // pageBuilder constructs pages from AST. type pageBuilder struct { + ctx *ExecContext // execution context (for building-block expansion, etc.) backend backend.FullBackend moduleID model.ID moduleName string diff --git a/mdl/executor/cmd_pages_builder_v3.go b/mdl/executor/cmd_pages_builder_v3.go index b7f40c341..170a9ad33 100644 --- a/mdl/executor/cmd_pages_builder_v3.go +++ b/mdl/executor/cmd_pages_builder_v3.go @@ -11,6 +11,7 @@ import ( "github.com/mendixlabs/mxcli/mdl/ast" mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/mdl/types" + "github.com/mendixlabs/mxcli/mdl/visitor" "github.com/mendixlabs/mxcli/model" "github.com/mendixlabs/mxcli/sdk/domainmodel" "github.com/mendixlabs/mxcli/sdk/microflows" @@ -1732,13 +1733,23 @@ func (pb *pageBuilder) expandFragments(widgets []*ast.WidgetV3) ([]*ast.WidgetV3 return result, nil } -// expandIfFragment returns the widget as-is if it's not a USE_FRAGMENT sentinel, -// or expands it into cloned fragment widgets with optional prefix. +// expandIfFragment returns the widget as-is if it's not a USE_FRAGMENT or +// USE_BUILDING_BLOCK sentinel, or expands it into cloned/copied widgets with an +// optional prefix. func (pb *pageBuilder) expandIfFragment(w *ast.WidgetV3) ([]*ast.WidgetV3, error) { - if w.Type != "USE_FRAGMENT" { + switch w.Type { + case "USE_FRAGMENT": + return pb.expandFragmentRef(w) + case "USE_BUILDING_BLOCK": + return pb.expandBuildingBlockRef(w) + default: return []*ast.WidgetV3{w}, nil } +} +// expandFragmentRef expands a USE_FRAGMENT sentinel into cloned fragment widgets +// with an optional prefix rename. +func (pb *pageBuilder) expandFragmentRef(w *ast.WidgetV3) ([]*ast.WidgetV3, error) { if pb.fragments == nil { return nil, mdlerrors.NewNotFound("fragment", w.Name) } @@ -1754,6 +1765,78 @@ func (pb *pageBuilder) expandIfFragment(w *ast.WidgetV3) ([]*ast.WidgetV3, error return widgets, nil } +// expandBuildingBlockRef deep-copies a building block's widget tree into the +// page/container. The DESCRIBE renderer already emits faithful, re-parseable MDL +// for a block's widgets, so expansion = render the block's widgets to MDL text → +// re-parse them via a `define fragment` wrapper → apply the optional prefix. This +// reuses the whole existing widget parser instead of a hand-written BSON→AST +// converter. +func (pb *pageBuilder) expandBuildingBlockRef(w *ast.WidgetV3) ([]*ast.WidgetV3, error) { + if pb.ctx == nil { + return nil, mdlerrors.NewNotFound("building block", w.Name) + } + ctx := pb.ctx + + // Resolve the block by module + name. + qn := parseQualifiedNameStr(w.Name) + h, err := getHierarchy(ctx) + if err != nil { + return nil, mdlerrors.NewBackend("build hierarchy", err) + } + blocks, err := ctx.Backend.ListBuildingBlocks() + if err != nil { + return nil, mdlerrors.NewBackend("list building blocks", err) + } + var found *pages.BuildingBlock + for _, bb := range blocks { + modID := h.FindModuleID(bb.ContainerID) + modName := h.GetModuleName(modID) + if bb.Name == qn.Name && (qn.Module == "" || modName == qn.Module) { + found = bb + break + } + } + if found == nil { + return nil, mdlerrors.NewNotFound("building block", w.Name) + } + + // Read the block's widgets as raw BSON. + rawWidgets := getBuildingBlockWidgetsFromRaw(ctx, found.ID) + if len(rawWidgets) == 0 { + return nil, nil + } + + // Render to MDL text. outputWidgetMDLV3 writes to ctx.Output; redirect it to a + // buffer via a shallow ExecContext copy so we can capture the rendered MDL. + var sb strings.Builder + renderCtx := *ctx + renderCtx.Output = &sb + for _, rw := range rawWidgets { + outputWidgetMDLV3(&renderCtx, rw, 1) + } + + // Re-parse via a `define fragment` wrapper to obtain []*ast.WidgetV3. + src := "define fragment __bbtmp as {\n" + sb.String() + "\n};" + prog, errs := visitor.Build(src) + if len(errs) > 0 { + return nil, fmt.Errorf("use building block %s: could not expand widget tree: %v", w.Name, errs) + } + if len(prog.Statements) == 0 { + return nil, fmt.Errorf("use building block %s: could not expand widget tree", w.Name) + } + def, ok := prog.Statements[0].(*ast.DefineFragmentStmt) + if !ok { + return nil, fmt.Errorf("use building block %s: could not expand widget tree", w.Name) + } + widgets := def.Widgets + + // Apply the optional prefix rename. + if prefix, ok := w.Properties["Prefix"].(string); ok && prefix != "" { + prefixWidgetNames(widgets, prefix) + } + return widgets, nil +} + // cloneWidgets deep-copies a widget tree to avoid mutating the fragment definition. func cloneWidgets(widgets []*ast.WidgetV3) []*ast.WidgetV3 { if widgets == nil { diff --git a/mdl/executor/cmd_pages_create_v3.go b/mdl/executor/cmd_pages_create_v3.go index eeb1ab46e..e4af80621 100644 --- a/mdl/executor/cmd_pages_create_v3.go +++ b/mdl/executor/cmd_pages_create_v3.go @@ -58,6 +58,7 @@ func execCreatePageV3(ctx *ExecContext, s *ast.CreatePageStmtV3) error { // Build the page BEFORE deleting the old one (atomic: if build fails, old page is preserved) pb := &pageBuilder{ + ctx: ctx, backend: ctx.Backend, moduleID: moduleID, moduleName: s.Name.Module, @@ -138,6 +139,7 @@ func execCreateSnippetV3(ctx *ExecContext, s *ast.CreateSnippetStmtV3) error { // Build the snippet BEFORE deleting the old one (atomic: if build fails, old snippet is preserved) pb := &pageBuilder{ + ctx: ctx, backend: ctx.Backend, moduleID: moduleID, moduleName: s.Name.Module, diff --git a/mdl/executor/use_building_block_test.go b/mdl/executor/use_building_block_test.go new file mode 100644 index 000000000..3d18f3eff --- /dev/null +++ b/mdl/executor/use_building_block_test.go @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/mdl/visitor" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/pages" +) + +// bbRawWidgets returns a minimal building-block widget tree (a container holding +// a dynamictext) in the raw BSON shape that getBuildingBlockWidgetsFromRaw and +// parseRawWidget expect. +func bbRawWidgets() map[string]any { + return map[string]any{ + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DivContainer", + "Name": "container", + "Appearance": map[string]any{"Class": "card"}, + "Widgets": []any{ + map[string]any{ + "$Type": "Forms$DynamicText", + "Name": "cardtitle", + "Appearance": map[string]any{"Class": "card-title"}, + "Content": map[string]any{ + "Template": map[string]any{ + "Items": []any{ + map[string]any{"Text": "Card Title", "LanguageCode": "en_US"}, + }, + }, + }, + }, + }, + }, + }, + } +} + +// TestExpandBuildingBlock_Mock exercises USE BUILDING BLOCK expansion end to end +// through the executor: resolve the block, render its widgets to MDL, re-parse, +// and apply the prefix rename. Mirrors the fragment expansion tests. +func TestExpandBuildingBlock_Mock(t *testing.T) { + mod := mkModule("MyModule") + bb := mkBuildingBlock(mod.ID, "Card") + + h := mkHierarchy(mod) + withContainer(h, bb.ContainerID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListBuildingBlocksFunc: func() ([]*pages.BuildingBlock, error) { + return []*pages.BuildingBlock{bb}, nil + }, + GetRawUnitFunc: func(id model.ID) (map[string]any, error) { + return bbRawWidgets(), nil + }, + } + + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + + pb := &pageBuilder{ctx: ctx, backend: mb} + + w := &ast.WidgetV3{ + Type: "USE_BUILDING_BLOCK", + Name: "MyModule.Card", + Properties: map[string]interface{}{"Prefix": "p_"}, + } + + result, err := pb.expandIfFragment(w) + if err != nil { + t.Fatalf("expandIfFragment failed: %v", err) + } + if len(result) != 1 { + t.Fatalf("expected 1 top-level widget, got %d", len(result)) + } + + // Top-level widget is the prefixed container. + container := result[0] + if container.Type != "container" { + t.Errorf("expected container type, got %q", container.Type) + } + if container.Name != "p_container" { + t.Errorf("expected prefixed container name 'p_container', got %q", container.Name) + } + if len(container.Children) != 1 { + t.Fatalf("expected 1 child widget, got %d", len(container.Children)) + } + + // Child is the prefixed dynamictext copied from the block. + child := container.Children[0] + if child.Type != "dynamictext" { + t.Errorf("expected dynamictext child, got %q", child.Type) + } + if child.Name != "p_cardtitle" { + t.Errorf("expected prefixed child name 'p_cardtitle', got %q", child.Name) + } +} + +// TestExpandBuildingBlock_NoPrefix verifies expansion without an `as` prefix +// keeps the block's original widget names. +func TestExpandBuildingBlock_NoPrefix(t *testing.T) { + mod := mkModule("MyModule") + bb := mkBuildingBlock(mod.ID, "Card") + + h := mkHierarchy(mod) + withContainer(h, bb.ContainerID, mod.ID) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListBuildingBlocksFunc: func() ([]*pages.BuildingBlock, error) { + return []*pages.BuildingBlock{bb}, nil + }, + GetRawUnitFunc: func(id model.ID) (map[string]any, error) { + return bbRawWidgets(), nil + }, + } + + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + pb := &pageBuilder{ctx: ctx, backend: mb} + + w := &ast.WidgetV3{ + Type: "USE_BUILDING_BLOCK", + Name: "MyModule.Card", + Properties: map[string]interface{}{"Prefix": ""}, + } + + result, err := pb.expandIfFragment(w) + if err != nil { + t.Fatalf("expandIfFragment failed: %v", err) + } + if len(result) != 1 || result[0].Name != "container" { + t.Fatalf("expected unprefixed 'container', got %+v", result) + } +} + +// TestExpandBuildingBlock_NotFound verifies a missing block errors cleanly. +func TestExpandBuildingBlock_NotFound(t *testing.T) { + mod := mkModule("MyModule") + h := mkHierarchy(mod) + + mb := &mock.MockBackend{ + IsConnectedFunc: func() bool { return true }, + ListBuildingBlocksFunc: func() ([]*pages.BuildingBlock, error) { + return []*pages.BuildingBlock{}, nil + }, + } + + ctx, _ := newMockCtx(t, withBackend(mb), withHierarchy(h)) + pb := &pageBuilder{ctx: ctx, backend: mb} + + w := &ast.WidgetV3{ + Type: "USE_BUILDING_BLOCK", + Name: "MyModule.Missing", + Properties: map[string]interface{}{"Prefix": ""}, + } + + _, err := pb.expandIfFragment(w) + if err == nil { + t.Fatal("expected error for missing building block") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("expected 'not found' error, got: %v", err) + } +} + +// TestUseBuildingBlockParses verifies the grammar+visitor produce the sentinel +// widget with the qualified name and prefix. +func TestUseBuildingBlockParses(t *testing.T) { + input := `define fragment Wrap as { + use building block Atlas_Web_Content.Card as cust_ + };` + + prog, errs := visitor.Build(input) + if len(errs) > 0 { + t.Fatalf("parse errors: %v", errs) + } + def, ok := prog.Statements[0].(*ast.DefineFragmentStmt) + if !ok { + t.Fatalf("expected DefineFragmentStmt, got %T", prog.Statements[0]) + } + if len(def.Widgets) != 1 { + t.Fatalf("expected 1 widget, got %d", len(def.Widgets)) + } + w := def.Widgets[0] + if w.Type != "USE_BUILDING_BLOCK" { + t.Errorf("expected USE_BUILDING_BLOCK, got %q", w.Type) + } + if w.Name != "Atlas_Web_Content.Card" { + t.Errorf("expected 'Atlas_Web_Content.Card', got %q", w.Name) + } + if p, _ := w.Properties["Prefix"].(string); p != "cust_" { + t.Errorf("expected prefix 'cust_', got %q", p) + } +} diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 7bb355ec7..f0c43e9c0 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -190,13 +190,13 @@ snippetHeaderPropertyV3 // `placeholder { … }` block binds its widgets to that named layout // placeholder (issue #532 — pages over a layout with >1 placeholder). pageBodyV3 - : (widgetV3 | useFragmentRef | placeholderBlockV3)* + : (widgetV3 | useFragmentRef | useBuildingBlockRef | placeholderBlockV3)* ; // PLACEHOLDER { widgets } — assign widgets to a named layout placeholder. // The name accepts keywords so placeholders like Right / Left / Content parse. placeholderBlockV3 - : PLACEHOLDER identifierOrKeyword LBRACE (widgetV3 | useFragmentRef)* RBRACE + : PLACEHOLDER identifierOrKeyword LBRACE (widgetV3 | useFragmentRef | useBuildingBlockRef)* RBRACE ; // USE FRAGMENT Name [AS prefix_] @@ -204,6 +204,12 @@ useFragmentRef : USE FRAGMENT identifierOrKeyword (AS identifierOrKeyword)? ; +// USE BUILDING BLOCK Module.Name [AS prefix_] +// Deep-copies the building block's widget tree into the page/container. +useBuildingBlockRef + : USE BUILDING BLOCK qualifiedName (AS identifierOrKeyword)? + ; + // V3 Widget: WIDGET name (Props) { children } // The name accepts QUOTED_IDENTIFIER in addition to IDENTIFIER so widgets named // after a reserved keyword (e.g. "List", "Column") can be expressed. DESCRIBE diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index a561d04e4..ee8b9f452 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -350,6 +350,10 @@ func buildPageBodyV3(ctx parser.IPageBodyV3Context, b *Builder) []*ast.WidgetV3 if ref := buildUseFragmentRef(c); ref != nil { widgets = append(widgets, ref) } + case *parser.UseBuildingBlockRefContext: + if ref := buildUseBuildingBlockRef(c); ref != nil { + widgets = append(widgets, ref) + } } } @@ -382,6 +386,10 @@ func buildPagePlaceholdersV3(ctx parser.IPageBodyV3Context, b *Builder) []*ast.P if ref := buildUseFragmentRef(c); ref != nil { ph.Widgets = append(ph.Widgets, ref) } + case *parser.UseBuildingBlockRefContext: + if ref := buildUseBuildingBlockRef(c); ref != nil { + ph.Widgets = append(ph.Widgets, ref) + } } } out = append(out, ph) @@ -417,6 +425,28 @@ func buildUseFragmentRef(ctx *parser.UseFragmentRefContext) *ast.WidgetV3 { return w } +// buildUseBuildingBlockRef creates a WidgetV3 with sentinel type USE_BUILDING_BLOCK. +// The Name holds the block's qualified name (e.g. "Atlas_Web_Content.Card"); the +// executor resolves the block, renders its widget tree to MDL, re-parses it, and +// deep-copies the widgets into the page/container with an optional prefix rename. +func buildUseBuildingBlockRef(ctx *parser.UseBuildingBlockRefContext) *ast.WidgetV3 { + if ctx == nil { + return nil + } + w := &ast.WidgetV3{ + Type: "USE_BUILDING_BLOCK", + Properties: make(map[string]interface{}), + } + if qn := ctx.QualifiedName(); qn != nil { + w.Name = buildQualifiedName(qn).String() // "Module.BlockName" + } + w.Properties["Prefix"] = "" + if idk := ctx.IdentifierOrKeyword(); idk != nil { + w.Properties["Prefix"] = identifierOrKeywordText(idk) // Optional prefix + } + return w +} + // buildWidgetV3 builds a V3 widget from a widgetV3 context. func buildWidgetV3(ctx parser.IWidgetV3Context, b *Builder) *ast.WidgetV3 { if ctx == nil {