Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/skills/mendix/alter-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,22 @@ insert after txtName {
insert before btnSave {
actionbutton btnPreview (caption: 'Preview', action: microflow Module.ACT_Preview)
}

-- Insert INTO a container — append as its last child (works on an EMPTY container)
insert into ctnToolbar {
actionbutton btnNew (caption: 'New', action: nothing, buttonstyle: primary)
}
```

Inserted widgets use the same syntax as `create page`. Multiple widgets can be inserted in a single block.

`insert into <container>` appends as the last child of the named container — the
only way to fill an **empty** container, and handy for adding to a container/dataview
without needing a sibling to anchor to. Widgets inserted into a dataview take that
dataview's entity as their context. Supported on simple containers (container,
dataview, groupbox, scroll-container region); for a layout grid or tab container,
insert relative to a widget inside the target column/tab instead.

### DROP - Remove Widgets

```sql
Expand Down
4 changes: 2 additions & 2 deletions cmd/mxcli/syntax/features_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func init() {
"set property", "insert widget", "drop widget", "replace widget",
"popup width", "popup height", "popup resizable",
},
Syntax: "ALTER PAGE Module.Name {\n SET property = value ON widgetName;\n SET (prop1 = val1, prop2 = val2) ON widgetName;\n SET Title = 'New Title'; -- page-level (case-sensitive)\n SET Class = 'css-class'; -- page-level CSS class / style\n SET Style = 'css: rule';\n SET PopupWidth = 800; -- page-level pop-up dimensions\n SET PopupHeight = 480;\n SET PopupResizable = true;\n INSERT AFTER widgetName { <widgets> };\n INSERT BEFORE widgetName { <widgets> };\n DROP WIDGET name1, name2;\n REPLACE widgetName WITH { <widgets> };\n};",
Syntax: "ALTER PAGE Module.Name {\n SET property = value ON widgetName;\n SET (prop1 = val1, prop2 = val2) ON widgetName;\n SET Title = 'New Title'; -- page-level (case-sensitive)\n SET Class = 'css-class'; -- page-level CSS class / style\n SET Style = 'css: rule';\n SET PopupWidth = 800; -- page-level pop-up dimensions\n SET PopupHeight = 480;\n SET PopupResizable = true;\n INSERT AFTER widgetName { <widgets> };\n INSERT BEFORE widgetName { <widgets> };\n INSERT INTO containerName { <widgets> };\n DROP WIDGET name1, name2;\n REPLACE widgetName WITH { <widgets> };\n};",
Example: "ALTER PAGE Module.EditPage {\n SET (Caption = 'Save & Close', ButtonStyle = Success) ON btnSave;\n INSERT AFTER txtName {\n TEXTBOX txtMiddleName (Label: 'Middle Name', Binds: MiddleName)\n };\n DROP WIDGET txtUnused;\n};",
SeeAlso: []string{"page.create", "page.show", "snippet.alter"},
})
Expand Down Expand Up @@ -152,7 +152,7 @@ func init() {
Keywords: []string{
"alter snippet", "modify snippet", "update snippet",
},
Syntax: "ALTER SNIPPET Module.Name {\n SET property = value ON widgetName;\n INSERT AFTER widgetName { <widgets> };\n INSERT BEFORE widgetName { <widgets> };\n DROP WIDGET name1, name2;\n REPLACE widgetName WITH { <widgets> };\n};",
Syntax: "ALTER SNIPPET Module.Name {\n SET property = value ON widgetName;\n INSERT AFTER widgetName { <widgets> };\n INSERT BEFORE widgetName { <widgets> };\n INSERT INTO containerName { <widgets> };\n DROP WIDGET name1, name2;\n REPLACE widgetName WITH { <widgets> };\n};",
Example: "ALTER SNIPPET Module.NavSnippet {\n REPLACE navItem1 WITH {\n ACTIONBUTTON btnHome (Caption: 'Home', Action: SHOW_PAGE Module.HomePage)\n };\n DROP WIDGET txtOldField;\n INSERT AFTER txtName {\n TEXTBOX txtNewField (Label: 'New Field', Binds: NewAttr)\n };\n};",
SeeAlso: []string{"snippet", "page.alter"},
})
Expand Down
1 change: 1 addition & 0 deletions docs-site/src/appendixes/quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ Modify an existing page or snippet's widget tree in-place without full `CREATE O
| Page-level set | `SET Title = 'New Title'` | No ON clause for page properties |
| Insert after | `INSERT AFTER widgetName { widgets }` | Add widgets after target |
| Insert before | `INSERT BEFORE widgetName { widgets }` | Add widgets before target |
| Insert into | `INSERT INTO containerName { widgets }` | Append as the container's last child (fills an empty container) |
| Drop widgets | `DROP WIDGET name1, name2` | Remove widgets by name |
| Replace widget | `REPLACE widgetName WITH { widgets }` | Replace widget subtree |
| Pluggable prop | `SET 'showLabel' = false ON cbStatus` | Quoted name for pluggable widgets |
Expand Down
12 changes: 12 additions & 0 deletions docs-site/src/examples/alter-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ ALTER PAGE CRM.Customer_Edit {
};
```

## Add a Widget Into a Container

Use `INSERT INTO` to append a widget as a container's last child — the only way to fill an empty container.

```sql
ALTER PAGE CRM.Customer_Overview {
INSERT INTO ctnToolbar {
ACTIONBUTTON btnNew (Caption: 'New Customer', Action: NOTHING, ButtonStyle: Primary)
}
};
```

## Remove Widgets

```sql
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/language/alter-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,17 @@ ALTER PAGE Module.EditPage {
ACTIONBUTTON btnPreview (Caption: 'Preview', Action: MICROFLOW Module.ACT_Preview)
}
};

-- Insert INTO a container — append as its last child (works on an empty container)
ALTER PAGE Module.EditPage {
INSERT INTO ctnToolbar {
ACTIONBUTTON btnNew (Caption: 'New', Action: NOTHING, ButtonStyle: Primary)
}
};
```

`INSERT INTO <container>` appends widgets as the container's last children — the only way to fill an **empty** container, and handy for adding to a container or data view without a sibling to anchor to. Widgets inserted into a data view take that data view's entity. Supported on simple containers; for a layout grid or tab container, insert relative to a widget inside the target column/tab instead.

The inserted widgets use the same syntax as in `CREATE PAGE`. Multiple widgets can be inserted in a single block.

### DROP WIDGET -- Remove Widgets
Expand Down
3 changes: 3 additions & 0 deletions docs-site/src/language/snippets.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ ALTER SNIPPET MyModule.CustomerCard {
SET Caption = 'View Details' ON btnEdit;
INSERT AFTER txtEmail {
DYNAMICTEXT txtPhone (Content: '{1}', Attribute: Phone)
};
INSERT INTO ctnActions { -- append as the container's last child
ACTIONBUTTON btnMore (Caption: 'More', Action: NOTHING)
}
};
```
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/reference/page/alter-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ SET 'propertyName' = value ON widgetName;
INSERT BEFORE widgetName { widget_definitions };
INSERT AFTER widgetName { widget_definitions };

-- Insert widgets as the last children of a container
INSERT INTO containerName { widget_definitions };

-- Remove widgets
DROP WIDGET widgetName1, widgetName2;

Expand Down Expand Up @@ -68,6 +71,12 @@ For pluggable widget properties, use quoted property names (e.g., `'showLabel'`)

Inserts new widgets immediately before or after a named widget within its parent container. The new widgets use the same syntax as in `CREATE PAGE`.

### INSERT INTO

Appends new widgets as the **last children** of a named container. This is the only way to fill an **empty** container, and the natural way to add a widget to a container or data view without needing a sibling to anchor to. Widgets inserted into a data view take that data view's entity as their context.

Supported on simple containers (container/DivContainer, data view, group box, scroll-container region). A layout grid (rows/columns) and tab container have no single child list — insert relative to a widget inside the target column or tab instead.

### DROP WIDGET

Removes one or more widgets by name. The widget and all its children are removed from the tree.
Expand Down
1 change: 1 addition & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -982,6 +982,7 @@ Modify an existing page or snippet's widget tree in-place without full `create o
| Widget dynamic classes | `set DynamicClasses = 'expr' on widgetName` | Runtime-computed classes on a widget — the surgical alternative to a bulk `update widgets` |
| Insert after | `insert after widgetName { widgets }` | Add widgets after target |
| Insert before | `insert before widgetName { widgets }` | Add widgets before target |
| Insert into | `insert into containerName { widgets }` | Append as the container's last child (fills an empty container; dataview children take its entity) |
| Drop widgets | `drop widget name1, name2` | Remove widgets by name |
| Replace widget | `replace widgetName with { widgets }` | Replace widget subtree |
| Pluggable prop | `set 'showLabel' = false on cbStatus` | Quoted name for pluggable widgets |
Expand Down
1 change: 1 addition & 0 deletions docs/05-mdl-specification/01-language-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ set title = 'New Title';
-- Insert widgets
insert after widgetName { <widgets> };
insert before widgetName { <widgets> };
insert into containerName { <widgets> }; -- append as the container's last child

-- Remove widgets
drop widget name1, name2;
Expand Down
13 changes: 13 additions & 0 deletions mdl-examples/doctype-tests/33-alter-page-examples.mdl
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ alter page AlterPg.Product_Overview {
}
};

-- MARK: AP02b — INSERT INTO a container (append as last child)

-- ============================================================================
-- AP02b: INSERT INTO — append widgets as a container's children. This is the
-- only way to fill an EMPTY container (here, the `divider` just created above).
-- ============================================================================

alter page AlterPg.Product_Overview {
insert into divider {
dynamictext dividerNote (Content: 'Catalog below', RenderMode: paragraph)
}
};

-- MARK: AP03 — INSERT a DataGrid2 with initial columns

-- ============================================================================
Expand Down
50 changes: 50 additions & 0 deletions mdl-examples/doctype-tests/alter-page-insert-into.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
-- ============================================================================
-- ALTER PAGE — INSERT INTO <container>
-- ============================================================================
-- INSERT INTO appends widgets as the LAST children of a named container, rather
-- than as a sibling before/after another widget. This is the only way to fill an
-- EMPTY container, and the natural way to add a widget to a container/dataview
-- without needing a sibling to anchor to.
--
-- insert into <container> { <widgets> }
--
-- Simple containers are supported (container/DivContainer, dataview, groupbox,
-- scroll-container region). For a layout grid or tab container, insert relative to
-- a widget inside the target column/tab instead.
-- ============================================================================

create module AlterInto;
/
create entity AlterInto.Item ( Title: String, Status: String );
/
create or replace page AlterInto.Home ( layout: Atlas_Core.Atlas_Default ) {
container ctnEmpty (class: 'toolbar') { }
container ctnBody (class: 'body') {
dynamictext dtExisting (content: 'Existing content')
}
}
/
create or replace page AlterInto.Detail ( params: { $Item: AlterInto.Item }, layout: Atlas_Core.Atlas_Default ) {
dataview dv (datasource: $Item) { }
}
/
-- Fill an empty container.
alter page AlterInto.Home {
insert into ctnEmpty {
actionbutton btnNew (caption: 'New', action: nothing, buttonstyle: primary)
}
}
/
-- Append as the last child of a container that already has widgets.
alter page AlterInto.Home {
insert into ctnBody {
pluggablewidget 'com.mendix.widget.custom.badge.Badge' bdgStatus (type: 'badge', value: 'Active')
}
}
/
-- Insert a data-bound widget into a dataview — the children take the dataview's entity.
alter page AlterInto.Detail {
insert into dv {
dynamictext dtTitle (content: '{1}', contentparams: [{1} = Title], rendermode: H1)
}
}
6 changes: 3 additions & 3 deletions mdl/ast/ast_alter_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ type SetPropertyOp struct {

func (s *SetPropertyOp) isAlterPageOperation() {}

// InsertWidgetOp represents: INSERT AFTER/BEFORE widgetRef { widgets }
// InsertWidgetOp represents: INSERT AFTER/BEFORE/INTO widgetRef { widgets }
type InsertWidgetOp struct {
Position string // "AFTER" or "BEFORE"
Target WidgetRef // widget/column to insert relative to
Position string // "AFTER", "BEFORE", or "INTO" (append as children of the container)
Target WidgetRef // widget to insert relative to, or container to insert into
Widgets []*WidgetV3
}

Expand Down
12 changes: 11 additions & 1 deletion mdl/backend/mcp/page_mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,24 @@ func (m *mcpPageMutator) InsertWidget(widgetRef string, columnRef string, positi
if columnRef != "" {
return fmt.Errorf("inserting into a column (%s.%s) is not yet supported by the MCP backend", widgetRef, columnRef)
}
parent, key, idx, _, ok := findWidget(m.content, widgetRef)
parent, key, idx, widget, ok := findWidget(m.content, widgetRef)
if !ok {
return fmt.Errorf("widget %q not found", widgetRef)
}
mapped, err := m.backend.mapPageWidgets(widgets)
if err != nil {
return err
}
// INSERT INTO: append as children of the target container itself.
if strings.EqualFold(string(position), "into") {
if _, hasChildren := widget["widgets"]; !hasChildren {
return fmt.Errorf("cannot INSERT INTO %q (%v): it has no direct child list — "+
"use INSERT BEFORE/AFTER a widget inside the target column or tab instead", widgetRef, widget["$Type"])
}
children, _ := widget["widgets"].([]any)
widget["widgets"] = append(append([]any{}, children...), mapped...)
return nil
}
arr, _ := parent[key].([]any)
at := idx
// The executor passes the AST token ("AFTER"/"BEFORE"), so compare case-insensitively.
Expand Down
16 changes: 16 additions & 0 deletions mdl/backend/mcp/page_mutator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ func TestPageMutator_InsertAfterBefore(t *testing.T) {
}
}

func TestPageMutator_InsertInto(t *testing.T) {
m := newTestMutator()
// INSERT INTO appends as the last child of the container itself (dv), not a sibling.
if err := m.InsertWidget("dv", "", backend.InsertPosition("INTO"), []pages.Widget{dynText("t2")}); err != nil {
t.Fatal(err)
}
if got := dvChildNames(m); len(got) != 2 || got[0] != "t1" || got[1] != "t2" {
t.Fatalf("after INTO insert: %v (want [t1 t2])", got)
}
// Inserting into a non-container (the DynamicText t1) is a clear error.
err := m.InsertWidget("t1", "", backend.InsertPosition("INTO"), []pages.Widget{dynText("x")})
if err == nil || !strings.Contains(err.Error(), "INSERT INTO") {
t.Fatalf("expected INSERT INTO error for non-container, got %v", err)
}
}

func TestPageMutator_ReplaceAndDrop(t *testing.T) {
m := newTestMutator()
_ = m.InsertWidget("t1", "", backend.InsertPosition("AFTER"), []pages.Widget{dynText("t2")})
Expand Down
87 changes: 87 additions & 0 deletions mdl/backend/pagemutator/mutator.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,21 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back
return fmt.Errorf("serialize widgets: %w", err)
}

// INSERT INTO: append the widgets as children of the target container itself
// (its `Widgets` array), rather than as siblings in the target's parent array.
// Enables inserting into an empty container or as a container's last child.
if strings.EqualFold(string(position), "into") {
newContainer, err := appendChildrenToContainer(result.widget, widgetRef, newBsonWidgets)
if err != nil {
return err
}
// Write the (possibly reallocated — an empty container gains a new Widgets
// field) container doc back into its parent slot.
result.parentArr[result.index] = newContainer
bsonnav.DSetArray(result.parentDoc, result.parentKey, result.parentArr)
return nil
}

insertIdx := result.index
if strings.EqualFold(string(position), "after") {
insertIdx = result.index + 1
Expand All @@ -198,6 +213,78 @@ func (m *Mutator) InsertWidget(widgetRef string, columnRef string, position back
return nil
}

// insertIntoContainer appends widgets as the last children of a container widget
// (its `Widgets` array). Simple containers (Pages$DivContainer, Forms$Container,
// DataView, GroupBox, ScrollContainer, …) keep their children in a single
// `Widgets` list. LayoutGrid (Rows/Columns) and TabContainer (TabPages) have no
// single child list, so INSERT INTO can't target them directly — the caller is
// told to insert relative to a widget inside the target column/tab instead.
// appendChildrenToContainer appends widgets to a container's `Widgets` list and
// returns the (possibly reallocated) container doc. An empty container omits the
// Widgets field entirely, so it is added — which grows the bson.D slice, hence the
// caller must store the returned doc back into its parent slot.
func appendChildrenToContainer(container bson.D, widgetRef string, newBsonWidgets []any) (bson.D, error) {
if !containerAcceptsWidgets(container) {
typeName := bsonnav.DGetString(container, "$Type")
return nil, fmt.Errorf("cannot INSERT INTO %q (%s): it is not a simple container — "+
"use INSERT BEFORE/AFTER a widget inside the target column or tab instead", widgetRef, typeName)
}
// Append after any existing children, preserving the Mendix list marker (a
// leading int32). An empty container omits the Widgets field, so create it with
// the default marker (2) that Mendix uses for widget lists.
raw := bsonnav.ToBsonA(bsonnav.DGet(container, "Widgets"))
var out bson.A
switch {
case len(raw) > 0 && isListMarker(raw[0]):
out = append(out, raw...) // marker + existing children
out = append(out, newBsonWidgets...)
case len(raw) == 0:
out = append(out, int32(2)) // fresh (empty container): Mendix widget-list marker
out = append(out, newBsonWidgets...)
default:
out = append(out, raw...) // children without a marker (unusual): keep as-is
out = append(out, newBsonWidgets...)
}
// DSet updates an existing field in place; if Widgets is absent (empty
// container), append the field, growing the slice.
if bsonnav.DSet(container, "Widgets", out) {
return container, nil
}
return append(container, bson.E{Key: "Widgets", Value: out}), nil
}

// isListMarker reports whether a value is a Mendix list-version marker (a leading int).
func isListMarker(v any) bool {
switch v.(type) {
case int32, int:
return true
}
return false
}

// containerAcceptsWidgets reports whether a widget keeps its children in a single
// `Widgets` list that INSERT INTO can append to. A container that currently holds
// children always has the field; an empty one omits it, so recognise the common
// simple-container types by $Type. LayoutGrid (Rows/Columns) and TabContainer
// (TabPages) are intentionally excluded — they have no single child list.
func containerAcceptsWidgets(container bson.D) bool {
for _, e := range container {
if e.Key == "Widgets" {
return true
}
}
switch bsonnav.DGetString(container, "$Type") {
case "Forms$DivContainer",
"Forms$Container",
"Forms$DataView",
"Forms$GroupBox",
"Forms$ScrollContainerRegion",
"Forms$Section":
return true
}
return false
}

func (m *Mutator) DropWidget(refs []backend.WidgetRef) error {
for _, ref := range refs {
// Re-find widget each iteration because previous drops mutate the tree.
Expand Down
8 changes: 7 additions & 1 deletion mdl/executor/cmd_alter_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,14 @@ func applyInsertWidgetMutator(ctx *ExecContext, mutator backend.PageMutator, op
return mutator.InsertColumns(op.Target.Widget, op.Target.Column, backend.InsertPosition(op.Position), specs)
}

// Find entity context from enclosing DataView/DataGrid/ListView for regular widget insert.
// Find entity context for the new widgets. For INSERT BEFORE/AFTER the target
// is a sibling, so use its enclosing container's context; for INSERT INTO the
// target IS the container, so the children take the target's own context (e.g.
// a dataview's entity).
entityCtx := mutator.EnclosingEntity(op.Target.Widget)
if strings.EqualFold(op.Position, "INTO") {
entityCtx = mutator.EnclosingEntityForChildren(op.Target.Widget)
}

// Build new widgets from AST
widgets, err := buildWidgetsFromAST(ctx, op.Widgets, moduleName, moduleID, entityCtx, mutator)
Expand Down
1 change: 1 addition & 0 deletions mdl/grammar/MDLParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ alterPageAssignment
alterPageInsert
: INSERT AFTER widgetRef LBRACE pageBodyV3 RBRACE
| INSERT BEFORE widgetRef LBRACE pageBodyV3 RBRACE
| INSERT INTO widgetRef LBRACE pageBodyV3 RBRACE // append as children of a container
;

alterPageDrop
Expand Down
Loading
Loading