Skip to content

Commit fd09fa6

Browse files
akoclaude
andcommitted
feat: add ALTER PAGE SET Layout to switch page layout (#54)
Adds SET Layout = Module.LayoutName [MAP (Old AS New)] to ALTER PAGE. Rewrites FormCall.Form and Parameter strings without rebuilding the widget tree. Auto-maps placeholders by name; explicit MAP clause for layouts with different placeholder names. Not supported for snippets. Full pipeline: grammar, AST, visitor, executor, plus docs (quick reference, language guide, reference page, examples, skills, CLAUDE.md) and doctype-test examples. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c77320a commit fd09fa6

File tree

15 files changed

+8593
-7952
lines changed

15 files changed

+8593
-7952
lines changed

.claude/skills/mendix/alter-page.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ SET Title = 'New Page Title'
6464
| `Visible` | Any widget | String or Boolean | `SET Visible = false ON txtHidden` |
6565
| `Name` | Any widget | String | `SET Name = 'newName' ON oldName` |
6666
| `Title` | Page-level only | String | `SET Title = 'Edit Customer'` |
67+
| `Layout` | Page-level only | Qualified name | `SET Layout = Atlas_Core.Atlas_Default` |
6768
| `'quotedProp'` | Pluggable widgets | String, Boolean, Number | `SET 'showLabel' = false ON cbStatus` |
6869

6970
**Pluggable widget properties** use quoted names to set values in the widget's `Object.Properties[]`. Boolean values are stored as `"yes"`/`"no"` in BSON.
@@ -139,6 +140,20 @@ DROP Variables $showStockColumn
139140

140141
Removes a page variable by name.
141142

143+
### SET Layout - Change Page Layout
144+
145+
```sql
146+
-- Auto-map placeholders by name (most common case)
147+
SET Layout = Atlas_Core.Atlas_Default
148+
149+
-- Explicit mapping when placeholder names differ
150+
SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar)
151+
```
152+
153+
Changes the page's layout without rebuilding the widget tree. Only rewrites the `FormCall.Form` and `FormCall.Arguments[].Parameter` BSON fields — all widget content is preserved. Not supported for snippets.
154+
155+
When placeholders have the same names in both layouts (e.g., both have `Main`), auto-mapping works. Use `MAP` when placeholder names differ between the old and new layout.
156+
142157
## Examples
143158

144159
### Change button text and style

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ Regenerate after modifying `MDLLexer.g4` or `MDLParser.g4`: `make grammar`. See
345345
**Read the relevant skill files FIRST before writing any MDL, seeding data, or doing database/import work:**
346346
- `.claude/skills/write-microflows.md` - Microflow syntax, common mistakes, validation checklist
347347
- `.claude/skills/create-page.md` - Page/widget syntax reference
348-
- `.claude/skills/alter-page.md` - ALTER PAGE/SNIPPET in-place modifications (SET, INSERT, DROP, REPLACE)
348+
- `.claude/skills/alter-page.md` - ALTER PAGE/SNIPPET in-place modifications (SET, INSERT, DROP, REPLACE, SET Layout)
349349
- `.claude/skills/overview-pages.md` - CRUD page patterns
350350
- `.claude/skills/master-detail-pages.md` - Master-detail page patterns
351351
- `.claude/skills/generate-domain-model.md` - Entity/Association syntax

docs-site/src/examples/alter-page.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,25 @@ ALTER PAGE CRM.ProductOverview {
6363
};
6464
```
6565

66+
## Switch Page Layout
67+
68+
Change a page's layout without losing any widgets:
69+
70+
```sql
71+
-- Switch from TopBar to Default layout (auto-maps by placeholder name)
72+
ALTER PAGE CRM.Customer_Edit {
73+
SET Layout = Atlas_Core.Atlas_Default
74+
};
75+
```
76+
77+
When the new layout has different placeholder names, use `MAP`:
78+
79+
```sql
80+
ALTER PAGE CRM.Customer_Edit {
81+
SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar)
82+
};
83+
```
84+
6685
## Works on Snippets Too
6786

6887
```sql

docs-site/src/language/alter-page.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,26 @@ ALTER PAGE Module.EditPage {
6767
};
6868
```
6969

70+
### SET Layout -- Change Page Layout
71+
72+
Switch a page's layout without rebuilding the widget tree. All widget content is preserved -- only the layout reference and placeholder mappings are updated.
73+
74+
```sql
75+
-- Auto-map placeholders by name (common case)
76+
ALTER PAGE Module.EditPage {
77+
SET Layout = Atlas_Core.Atlas_Default
78+
};
79+
80+
-- Explicit mapping when placeholder names differ
81+
ALTER PAGE Module.EditPage {
82+
SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar)
83+
};
84+
```
85+
86+
When both the old and new layouts share the same placeholder names (e.g., both have `Main`), no `MAP` clause is needed -- placeholders are matched automatically. Use `MAP` when the new layout has different placeholder names.
87+
88+
Not supported for snippets (snippets don't have layouts).
89+
7090
### INSERT -- Add Widgets
7191

7292
Insert new widgets before or after an existing widget:

docs-site/src/reference/page/alter-page.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ ADD Variables $name : type = 'expression';
4040

4141
-- Drop a page variable
4242
DROP Variables $name;
43+
44+
-- Change page layout (auto-map placeholders by name)
45+
SET Layout = module.LayoutName;
46+
47+
-- Change layout with explicit placeholder mapping
48+
SET Layout = module.LayoutName MAP (OldPlaceholder AS NewPlaceholder);
4349
```
4450

4551
## Description
@@ -70,6 +76,12 @@ Removes one or more widgets by name. The widget and all its children are removed
7076

7177
Replaces a widget (and its entire subtree) with one or more new widgets.
7278

79+
### SET Layout
80+
81+
Changes the page's layout without rebuilding the widget tree. Placeholder names are auto-mapped by default. If the new layout has different placeholder names, use `MAP` to specify the mapping.
82+
83+
Not supported for snippets (snippets don't have layouts).
84+
7385
### ADD Variables / DROP Variables
7486

7587
Adds or removes page-level variables. Page variables are typed values with default expressions, available for conditional visibility and dynamic behavior.
@@ -148,6 +160,22 @@ ALTER PAGE Sales.Order_Edit {
148160
};
149161
```
150162

163+
Change page layout (preserves all widgets):
164+
165+
```sql
166+
ALTER PAGE Sales.Order_Edit {
167+
SET Layout = Atlas_Core.Atlas_Default;
168+
};
169+
```
170+
171+
Change layout with explicit placeholder mapping:
172+
173+
```sql
174+
ALTER PAGE Sales.Order_Edit {
175+
SET Layout = Atlas_Core.Atlas_SideBar MAP (Main AS Content, Extra AS Sidebar);
176+
};
177+
```
178+
151179
Modify a snippet:
152180

153181
```sql

docs/01-project/MDL_QUICK_REFERENCE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,10 @@ Modify an existing page or snippet's widget tree in-place without full `CREATE O
445445
| Pluggable prop | `SET 'showLabel' = false ON cbStatus` | Quoted name for pluggable widgets |
446446
| Add variable | `ADD Variables $name: Type = 'expr'` | Add a page variable |
447447
| Drop variable | `DROP Variables $name` | Remove a page variable |
448+
| Set layout | `SET Layout = Module.LayoutName` | Change page layout, auto-maps placeholders |
449+
| Set layout + map | `SET Layout = Module.Layout MAP (Old AS New)` | Explicit placeholder mapping |
448450

449-
**Supported SET properties:** Caption, Label, ButtonStyle, Class, Style, Editable, Visible, Name, Title (page-level), and quoted pluggable widget properties.
451+
**Supported SET properties:** Caption, Label, ButtonStyle, Class, Style, Editable, Visible, Name, Title (page-level), Layout (page-level), and quoted pluggable widget properties.
450452

451453
**Example:**
452454
```sql

mdl-examples/doctype-tests/03-page-examples.mdl

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2238,6 +2238,26 @@ ALTER PAGE PgTest.P033b_DataGrid_ColumnProperties {
22382238
DROP Variables $showPriceColumn;
22392239
};
22402240

2241+
-- MARK: ALTER PAGE SET Layout
2242+
2243+
-- =============================================================================
2244+
-- ALTER PAGE - SET Layout (change page layout without rebuilding widgets)
2245+
-- =============================================================================
2246+
2247+
/**
2248+
* Level 8.1: Change page layout (auto-map placeholders)
2249+
*/
2250+
ALTER PAGE PgTest.P033b_DataGrid_ColumnProperties {
2251+
SET Layout = Atlas_Core.Atlas_Default;
2252+
};
2253+
2254+
/**
2255+
* Level 8.2: Change layout back (auto-map)
2256+
*/
2257+
ALTER PAGE PgTest.P033b_DataGrid_ColumnProperties {
2258+
SET Layout = Atlas_Core.Atlas_TopBar;
2259+
};
2260+
22412261
-- MARK: Move Examples
22422262

22432263
-- =============================================================================

mdl/ast/ast_alter_page.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,17 @@ type DropVariableOp struct {
6666
}
6767

6868
func (s *DropVariableOp) isAlterPageOperation() {}
69+
70+
// SetLayoutOp represents: SET Layout = Module.LayoutName [MAP (Old -> New, ...)]
71+
type SetLayoutOp struct {
72+
NewLayout QualifiedName // New layout qualified name
73+
Mappings map[string]string // Old placeholder -> New placeholder (nil = auto-map)
74+
}
75+
76+
func (s *SetLayoutOp) isAlterPageOperation() {}
77+
78+
// LayoutMapping represents a single placeholder mapping: Old -> New
79+
type LayoutMapping struct {
80+
From string
81+
To string
82+
}

mdl/executor/cmd_alter_page.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ func (e *Executor) execAlterPage(s *ast.AlterPageStmt) error {
9797
if err := applyDropVariable(rawData, o); err != nil {
9898
return fmt.Errorf("DROP VARIABLE failed: %w", err)
9999
}
100+
case *ast.SetLayoutOp:
101+
if containerType == "SNIPPET" {
102+
return fmt.Errorf("SET Layout is not supported for snippets")
103+
}
104+
if err := applySetLayout(rawData, o); err != nil {
105+
return fmt.Errorf("SET Layout failed: %w", err)
106+
}
100107
default:
101108
return fmt.Errorf("unknown ALTER %s operation type: %T", containerType, op)
102109
}
@@ -117,6 +124,139 @@ func (e *Executor) execAlterPage(s *ast.AlterPageStmt) error {
117124
return nil
118125
}
119126

127+
// applySetLayout rewrites the FormCall to reference a new layout.
128+
// It updates the Form field and remaps Parameter strings in each FormCallArgument.
129+
func applySetLayout(rawData bson.D, op *ast.SetLayoutOp) error {
130+
newLayoutQN := op.NewLayout.Module + "." + op.NewLayout.Name
131+
132+
// Find FormCall in the page BSON
133+
var formCall bson.D
134+
for _, elem := range rawData {
135+
if elem.Key == "FormCall" {
136+
if doc, ok := elem.Value.(bson.D); ok {
137+
formCall = doc
138+
}
139+
break
140+
}
141+
}
142+
if formCall == nil {
143+
return fmt.Errorf("page has no FormCall (layout reference)")
144+
}
145+
146+
// Detect the old layout name from existing Parameter values
147+
oldLayoutQN := ""
148+
for _, elem := range formCall {
149+
if elem.Key == "Form" {
150+
if s, ok := elem.Value.(string); ok && s != "" {
151+
oldLayoutQN = s
152+
}
153+
}
154+
if elem.Key == "Arguments" {
155+
if arr, ok := elem.Value.(bson.A); ok {
156+
for _, item := range arr {
157+
if doc, ok := item.(bson.D); ok {
158+
for _, field := range doc {
159+
if field.Key == "Parameter" {
160+
if s, ok := field.Value.(string); ok && oldLayoutQN == "" {
161+
// Extract layout QN from "Atlas_Core.Atlas_TopBar.Main"
162+
if lastDot := strings.LastIndex(s, "."); lastDot > 0 {
163+
oldLayoutQN = s[:lastDot]
164+
}
165+
}
166+
}
167+
}
168+
}
169+
}
170+
}
171+
}
172+
}
173+
174+
if oldLayoutQN == "" {
175+
return fmt.Errorf("cannot determine current layout from FormCall")
176+
}
177+
178+
if oldLayoutQN == newLayoutQN {
179+
return nil // Already using the target layout
180+
}
181+
182+
// Update Form field
183+
for i, elem := range formCall {
184+
if elem.Key == "Form" {
185+
formCall[i].Value = newLayoutQN
186+
}
187+
}
188+
189+
// If Form field doesn't exist, add it
190+
hasForm := false
191+
for _, elem := range formCall {
192+
if elem.Key == "Form" {
193+
hasForm = true
194+
break
195+
}
196+
}
197+
if !hasForm {
198+
// Insert before Arguments
199+
for i, elem := range formCall {
200+
if elem.Key == "Arguments" {
201+
formCall = append(formCall[:i+1], formCall[i:]...)
202+
formCall[i] = bson.E{Key: "Form", Value: newLayoutQN}
203+
break
204+
}
205+
}
206+
}
207+
208+
// Remap Parameter strings in each FormCallArgument
209+
for _, elem := range formCall {
210+
if elem.Key != "Arguments" {
211+
continue
212+
}
213+
arr, ok := elem.Value.(bson.A)
214+
if !ok {
215+
continue
216+
}
217+
for _, item := range arr {
218+
doc, ok := item.(bson.D)
219+
if !ok {
220+
continue
221+
}
222+
for j, field := range doc {
223+
if field.Key != "Parameter" {
224+
continue
225+
}
226+
paramStr, ok := field.Value.(string)
227+
if !ok {
228+
continue
229+
}
230+
// Extract placeholder name: "Atlas_Core.Atlas_Default.Main" -> "Main"
231+
placeholder := paramStr
232+
if strings.HasPrefix(paramStr, oldLayoutQN+".") {
233+
placeholder = paramStr[len(oldLayoutQN)+1:]
234+
}
235+
236+
// Apply explicit mapping if provided
237+
if op.Mappings != nil {
238+
if mapped, ok := op.Mappings[placeholder]; ok {
239+
placeholder = mapped
240+
}
241+
}
242+
243+
// Write new parameter value
244+
doc[j].Value = newLayoutQN + "." + placeholder
245+
}
246+
}
247+
}
248+
249+
// Write FormCall back into rawData
250+
for i, elem := range rawData {
251+
if elem.Key == "FormCall" {
252+
rawData[i].Value = formCall
253+
break
254+
}
255+
}
256+
257+
return nil
258+
}
259+
120260
// ============================================================================
121261
// bson.D helper functions for ordered document access
122262
// ============================================================================

mdl/grammar/MDLParser.g4

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,11 +190,16 @@ alterPageOperation
190190
;
191191

192192
alterPageSet
193-
: SET alterPageAssignment ON identifierOrKeyword // SET Caption = 'Save' ON btnSave
193+
: SET LAYOUT EQUALS qualifiedName (MAP LPAREN alterLayoutMapping (COMMA alterLayoutMapping)* RPAREN)? // SET Layout = Atlas_Core.TopBar MAP (Main -> Main)
194+
| SET alterPageAssignment ON identifierOrKeyword // SET Caption = 'Save' ON btnSave
194195
| SET LPAREN alterPageAssignment (COMMA alterPageAssignment)* RPAREN ON identifierOrKeyword // SET (Caption = 'Save', ButtonStyle = Success) ON btnSave
195196
| SET alterPageAssignment // SET Title = 'Edit' (page-level)
196197
;
197198

199+
alterLayoutMapping
200+
: identifierOrKeyword AS identifierOrKeyword // OldPlaceholder AS NewPlaceholder
201+
;
202+
198203
alterPageAssignment
199204
: identifierOrKeyword EQUALS propertyValueV3 // Caption = 'Save'
200205
| STRING_LITERAL EQUALS propertyValueV3 // 'showLabel' = false

0 commit comments

Comments
 (0)