Overview Pages - CRUD Page Pattern

SkillDev tools

Lets your agent build standard list and edit pages for an entity using the MDL CRUD overview page pattern.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Overview Pages - CRUD Page Pattern skill

About this capability

The CRUD overview page pattern in MDL, a navigation snippet, a list page and a new/edit page wired together. Use when building the standard list-plus-edit screens for an entity.

What this skill tells your AI

The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/overview-pages/SKILL.md and read by ahel’s review.

Overview

Standard pattern for creating CRUD (Create, Read, Update, Delete) pages in Mendix using MDL syntax. This pattern consists of:

  1. Navigation Snippet - Reusable menu for consistent navigation
  2. Overview Page - Lists all objects with a DataGrid and navigation snippet
  3. NewEdit Page - Form for creating/editing a single object

Pattern Summary

ComponentTypePurposeKey Widgets
Entity_MenuSnippetVertical sidebar navigationNAVIGATIONLIST with ITEM actions
Entity_OverviewPageList all recordsSNIPPETCALL (sidebar), DATAGRID, Heading
Entity_NewEditPageCreate/Edit formDataView, Input widgets, Save/Cancel

Navigation Menu Snippet

Create a reusable navigation snippet using NAVIGATIONLIST for vertical sidebar menus:

create snippet Module.Entity_Menu
{
  navigationlist navMenu {
    item itemCustomers (caption: 'Customers', action: show_page Module.Customer_Overview)
    item itemOrders (caption: 'Orders', action: show_page Module.Order_Overview)
    item itemProducts (caption: 'Products', action: show_page Module.Product_Overview)
  }
}

Snippet Syntax

create [or replace] snippet Module.SnippetName
[(
  params: { $ParamName: Module.EntityType }
)]
[folder 'path']
{
  -- Widget definitions (same as pages)
}

NAVIGATIONLIST Syntax

The NAVIGATIONLIST widget creates a vertical menu with navigation items:

navigationlist widgetName {
  item itemName (caption: 'Caption', action: show_page Module.PageName)
  item itemName (caption: 'Caption', action: microflow Module.MicroflowName)
  item itemName (caption: 'Caption', action: close_page)
}

Overview Page Template

Lists all objects of an entity type with a data grid and navigation menu in a sidebar layout.

Layout Structure:

┌─────────────────────────────────────────────┐
│ layoutgrid                                  │
│ ┌────────┬──────────────────────────────────┤
│ │ COL 2  │ COL 10                           │
│ │ menu   │ Heading + datagrid               │
│ │snippet │                                  │
│ └────────┴──────────────────────────────────┤
└─────────────────────────────────────────────┘
create page Module.Entity_Overview
(
  title: 'Entity Overview',
  layout: Atlas_Core.Atlas_Default,
  folder: 'OverviewPages'
)
{
  layoutgrid mainGrid {
    row row1 {
      column colNav (desktopwidth: 2) {
        snippetcall navMenu (snippet: Module.Entity_Menu)
      }
      column colContent (desktopwidth: 10) {
        dynamictext heading (content: 'Entities', rendermode: H2)
        datagrid EntityGrid (
          datasource: database Module.Entity,
          selection: Multi,
          PagingPosition: both,
          designproperties: ['Compact': on, 'Hover': on, 'Striped': on]
        ) {
          column colName (attribute: Name, caption: 'Name') {
            textfilter textFilter1
          }
          column colDescription (attribute: Description, caption: 'Description') {
            textfilter textFilter2
          }
          column colActions (caption: 'Actions') {
            actionbutton btnEdit (caption: 'Edit', action: show_page Module.Entity_NewEdit("entity": $currentObject))
            actionbutton btnDelete (caption: 'Delete', action: delete, buttonstyle: danger)
          }
        }
      }
    }
  }
}

SNIPPETCALL Syntax

Include a snippet in a page using SNIPPETCALL:

-- Simple snippet call
snippetcall widgetName (snippet: Module.SnippetName)

-- With parameters (for parameterized snippets):
snippetcall widgetName (snippet: Module.SnippetName, params: {Customer: $Customer})

Overview Page Components

  1. Navigation Snippet: snippetcall referencing Module.NavigationMenu
  2. Layout: Atlas_Core.Atlas_Default - Full page with header/footer
  3. Heading: dynamictext with rendermode: H2
  4. Data Grid: datagrid with datasource: database binding

DATAGRID Syntax

datagrid GridName (
  datasource: database from Module.Entity where [IsActive = true] sort by Name asc,
  selection: Multi,
  PagingPosition: both,
  designproperties: ['Compact': on, 'Hover': on, 'Striped': on]
) {
  column colName (attribute: Name, caption: 'Name') {
    textfilter textFilter1
  }
  column colActions (caption: 'Actions') {
    actionbutton btnEdit (caption: 'Edit', action: show_page Module.Entity_NewEdit("entity": $currentObject))
  }
}

Properties:

  • datasource: database from Module.Entity - Entity data source (required)
  • where [condition] - Optional XPath filter (inline after entity in DataSource)
  • sort by attr asc|desc - Optional sorting (inline after WHERE: sort by Name asc, Price desc). A sort may navigate associations, one / per hop, with the last segment the attribute: sort by Order_BillTo/City asc. Name the hop when more than one association reaches the same entity — a bare Module.Address.City is resolved by inference, which cannot tell Order_ShipTo from Order_BillTo, and the wrong one builds cleanly and sorts by the wrong thing (mendixlabs/mxcli#1152)
  • selection: Multi - Multi-selection (Multi, Single, or omit for none)
  • PagingPosition: both - Pagination bar position (top, bottom, both)
  • designproperties: ['Compact': on, 'Hover': on, 'Striped': on] - Atlas design tokens

Column Types:

  • column colName (attribute: attribute, caption: 'label') - Attribute column (own-entity attribute)
  • column colName (attribute: Assoc/Attr, caption: 'label') - Associated-attribute column (attribute over a reference; bare association name, e.g. attribute: Order_Customer/Name; multi-hop A/B/Attr supported)
  • column colName (caption: 'label') { ... } - Custom content column (nested widgets)

Custom-content columns build correctly on the default engine (a nested actionbutton/dynamictext in column (caption: …) { … } — mxbuild-verified, 0 errors). An earlier CE0463 (column property ordering) was fixed. A row-level onclick (open the NewEdit page on row click) is still a fine alternative for a row-open affordance.

Reserved keyword column names: If the attribute name is a reserved MDL keyword (e.g. Status, Type), you must quote it and use a distinct column widget name:

column colStatus (attribute: "Status", caption: 'Status')
column colType   (attribute: "Type",   caption: 'Type')

Using COLUMN Status (attribute: Status) fails silently — the column won't sort or filter correctly because Status is parsed as a keyword. Always prefix the widget name (colStatus) when the attribute name is reserved.

Column Properties (non-default only in DESCRIBE output):

PropertyValuesDefault
Sortabletrue/falsetrue (with attribute)
Resizabletrue/falsetrue
Draggabletrue/falsetrue
Hidableyes/hidden/noyes
ColumnWidthautofill/autoFit/manualautofill
Sizeinteger (px)1 (when manual)
visibleexpressiontrue
DynamicCellClassexpression(empty)
tooltiptext(empty)

Column Filters (match the attribute's data type)

A filter widget must match the column attribute's type, or MxBuild fails with "The text filter is not compatible with the … data type". Do not apply textfilter to every column — it only works on String attributes. Pick by type:

Attribute typeFilter widget
Stringtextfilter
Integer / Long / Decimal / Autonumbernumberfilter
Date and timedatefilter
Enumerationdropdownfilter
Boolean(no filter — every filter widget errors on Boolean)
column colName   (attribute: Name)      { textfilter f1 }      -- String
column colQty    (attribute: Quantity)  { numberfilter f2 }    -- Integer/Decimal
column colDate   (attribute: OrderDate) { datefilter f3 }      -- Date and time
column colStatus (attribute: "Status")  { dropdownfilter f4 }  -- Enumeration
-- Boolean columns: omit the filter entirely

The filter goes inside the column's own braces. A filter { … } block is the GALLERY spelling of a different thing — the widget-wide filter bar, which a data grid calls controlbar:

-- ✅ data grid: per-column filter, inside the column
datagrid dg (...) { column colName (attribute: Name) { textfilter f1 } }

-- ✅ gallery: the widget-wide filter bar, which the gallery calls `filter`
gallery g (...) { filter f { textfilter f1 } }

-- ❌ the gallery form on a data grid — MDL-WIDGET30
datagrid dg (...) { column colName (attribute: Name) filter f { textfilter f1 } }

That last line is worth reading twice: it is not a column with a filter block. A widget is type name (props) { body }, so with the filter outside the column's braces it parses as a column with no body followed by a separate filter widget — which the grid has nowhere to put. It used to be dropped on write with no diagnostic, so DESCRIBE PAGE showing a filterless column was the only symptom; it is now refused at check and exec time.

A column over an association is filtered by the associated objects. The column shows a value from the other side (attribute: Order_Customer/Name); the filter takes the reference, the option list and what an option shows — all three, or it is refused:

column colCustomer (attribute: Order_Customer/Name, caption: 'Customer') {
  dropdownfilter fltCustomer (
    Association: Sales.Order_Customer,    -- the reference on the grid's entity
    datasource: database Sales.Customer,  -- the option list
    CaptionAttribute: Name                -- what each option shows
  )
}

A datefilter compares one date; FilterType: between makes the column a range.

The grid filters itself — do not build a filter bar beside it. The shape to avoid is a non-persistent filter entity, inputs bound to it, an apply microflow on every change, and an XPath on the grid reading that object back: measured on one generated app, three microflows and 1,100 characters of XPath against five lines, one filter per column.

NewEdit Page Template

Form for creating or editing a single entity. Requires a page parameter to receive the object.

create page Module.Entity_NewEdit
(
  params: { $entity: Module.Entity },
  title: 'Edit Entity',
  layout: Atlas_Core.PopupLayout,
  folder: 'OverviewPages'
)
{
  layoutgrid mainGrid {
    row row1 {
      column col1 (desktopwidth: autofill) {
        dataview dataView1 (datasource: $entity) {
          -- Input fields for each attribute
          textbox txtName (label: 'Name', attribute: Name)
          textbox txtDescription (label: 'Description', attribute: description)
          datepicker dpDueDate (label: 'Due Date', attribute: DueDate)
          combobox cbStatus (label: 'Status', attribute: status)

          footer footer1 {
            actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: success)
            actionbutton btnCancel (caption: 'Cancel', action: cancel_changes)
          }
        }
      }
    }
  }
}

Page Parameter Syntax

create page Module.PageName
(
  params: { $ParamName: Module.EntityName },
  title: '...',
  layout: ...
)
  • Parameter name conventionally matches the entity name (e.g., $store, $Customer)
  • The DataView's binding references this parameter (datasource: $ParamName)
  • When calling the page via SHOW_PAGE, pass the object by parameter name: show_page Module.PageName(ParamName: $value)
  • Reserved-word parameter names must be quoted in the SHOW_PAGE args. The generic examples above name the parameter entity, which is a reserved keyword, so the call quotes it: show_page Module.Entity_NewEdit("entity": $currentObject). A non-reserved name ($store, $Customer) needs no quotes: show_page Module.Store_NewEdit(store: $currentObject).

NewEdit Page Components

  1. Page Parameter: params: { $entity: Module.Entity } - Receives the object to edit
  2. Layout: Atlas_Core.PopupLayout - Popup/modal style
  3. DataView: Container bound to page parameter (datasource: $entity)
  4. Input Widgets: Match entity attributes with attribute: property
  5. Footer: Save and Cancel buttons

Complete Example: Store Entity

Step 1: Create the Navigation Snippet

First, create a navigation menu snippet that will be shared across all overview pages:

create snippet MdlTemplates.NavigationMenu
{
  layoutgrid navGrid {
    row row1 {
      column col1 (desktopwidth: 12) {
        actionbutton btnStores (caption: 'Stores', action: show_page MdlTemplates.Store_Overview)
        actionbutton btnCars (caption: 'Cars', action: show_page MdlTemplates.Car_Overview)
      }
    }
  }
}

Step 2: Create the Entity

create persistent entity MdlTemplates.Store (
  Name: string(200) not null,
  Location: string(200)
);

Step 3: Create the Overview Page

create page MdlTemplates.Store_Overview
(
  title: 'Store Overview',
  layout: Atlas_Core.Atlas_Default,
  folder: 'OverviewPages'
)
{
  layoutgrid mainGrid {
    row row1 {
      column col1 (desktopwidth: 12) {
        snippetcall navMenu (snippet: MdlTemplates.NavigationMenu)
      }
    }
    row row2 {
      column col2 (desktopwidth: 12) {
        dynamictext heading (content: 'Stores', rendermode: H2)
      }
    }
    row row3 {
      column col3 (desktopwidth: 12) {
        datagrid StoreGrid (datasource: database MdlTemplates.Store) {
          column colName (attribute: Name, caption: 'Name')
          column colLocation (attribute: Location, caption: 'Location')
        }
      }
    }
  }
}

Store NewEdit Page

create page MdlTemplates.Store_NewEdit
(
  params: { $store: MdlTemplates.Store },
  title: 'Edit Store',
  layout: Atlas_Core.PopupLayout,
  folder: 'OverviewPages'
)
{
  layoutgrid mainGrid {
    row row1 {
      column col1 (desktopwidth: autofill) {
        dataview dataView1 (datasource: $store) {
          textbox txtName (label: 'Name', attribute: Name)
          textbox txtLocation (label: 'Location', attribute: Location)

          footer footer1 {
            actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: success)
            actionbutton btnCancel (caption: 'Cancel', action: cancel_changes)
          }
        }
      }
    }
  }
}

Complete Example: Car Entity

Entity Definition

create persistent entity MdlTemplates.Car (
  Brand: string(200) not null,
  model: string(200),
  Price: decimal,
  PurchaseYear: integer,
  PurchaseDate: datetime,
  CarType: enumeration(MdlTemplates.CarType)
);

create enumeration MdlTemplates.CarType (
  Sedan 'Sedan',
  SUV 'SUV',
  Truck 'Truck',
  Sports 'Sports Car'
);

Car NewEdit Page

Shows various input widget types:

create page MdlTemplates.Car_NewEdit
(
  params: { $Car: MdlTemplates.Car },
  title: 'Edit Car',
  layout: Atlas_Core.PopupLayout,
  folder: 'OverviewPages'
)
{
  layoutgrid mainGrid {
    row row1 {
      column col1 (desktopwidth: autofill) {
        dataview dataView1 (datasource: $Car) {
          textbox txtBrand (label: 'Brand', attribute: Brand)
          textbox txtModel (label: 'Model', attribute: model)
          textbox txtPrice (label: 'Price', attribute: Price)
          textbox txtYear (label: 'Purchase year', attribute: PurchaseYear)
          datepicker dpDate (label: 'Purchase date', attribute: PurchaseDate)
          radiobuttons rbType (label: 'Car type', attribute: CarType)

          footer footer1 {
            actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: success)
            actionbutton btnCancel (caption: 'Cancel', action: cancel_changes)
          }
        }
      }
    }
  }
}

Widget Selection Guide

Choose input widgets based on attribute type:

Attribute TypeWidgetExample
StringtextboxName, Description
String (long)textareaComments, Notes
Integer, Long, DecimaltextboxPrice, Quantity
Booleancheckbox or radiobuttonsIsActive, IsPublished
DateTimedatepickerDueDate, OrderDate
Enumerationcombobox or radiobuttonsStatus, Type
Association (reference)combobox with DataSourceCategory, Owner

Note: dropdown is deprecated. Use combobox for enumeration attributes.

ComboBox modes:

  • Enum mode: combobox cb (label: 'status', attribute: status)
  • Association mode: combobox cb (label: 'Customer', attribute: Order_Customer, datasource: database MyModule.Customer, CaptionAttribute: Name)

Reserved Attribute Names: Do not use CreatedDate, ChangedDate, owner, ChangedBy as attribute names - these are system attributes automatically added to all entities.

Naming Conventions

ItemConventionExample
Navigation SnippetNavigationMenuMdlTemplates.NavigationMenu
Overview PageEntity_OverviewCustomer_Overview
NewEdit PageEntity_NewEditCustomer_NewEdit
FolderOverviewPages—
DataViewdataView1 or dv{entity}dvCustomer
DataGriddataGrid1 or dg{entity}dgCustomer
SnippetCallnavMenu or descriptive namenavMenu, headerSnippet

Button Styles

StyleUse CaseColor
successSave, ConfirmGreen
defaultCancel, BackGray
primaryPrimary actionBlue
dangerDeleteRed
warningCaution actionsYellow

Folder Organization

module/
├── snippets/
│   └── NavigationMenu
├── OverviewPages/
│   ├── Customer_Overview
│   ├── Customer_NewEdit
│   ├── Order_Overview
│   ├── Order_NewEdit
│   └── ...
├── microflows/
└── entities/

Parameterized Snippets

Snippets can accept parameters to display context-specific data. A snippet parameter must be an entity. A primitive one (params: { $Label: String }) is refused as MDL087, because Mendix rejects it with CE0046 "Invalid data type 'String'." — a page parameter may be a primitive, a snippet parameter may not. To parameterise a snippet on a value, keep the primitive on the calling page's parameters, or pass an object and read the member inside the snippet.

-- Create a snippet with a parameter
create snippet Module.CustomerDetails
(
  params: { $Customer: Module.Customer }
)
{
  layoutgrid detailsGrid {
    row row1 {
      column col1 (desktopwidth: 12) {
        dynamictext heading (content: 'Customer Details', rendermode: H3)
      }
    }
  }
}

-- Use the snippet with parameter passing
snippetcall customerDetails (snippet: Module.CustomerDetails, params: {Customer: $Customer})

Entity Menu Snippets with NavigationList

For entity-specific action menus (Edit, Delete, etc.), use the navigationlist widget:

create snippet Module.Entity_Menu
(
  params: { $EntityParameter: Module.Entity }
)
{
  navigationlist EntityMenuNav {
    item itemEdit (caption: 'Edit', action: show_page Module.Entity_NewEdit("entity": $EntityParameter))
    item itemDelete (caption: 'Delete', action: delete)
    item itemBack (caption: 'Back', action: close_page)
  }
}

NavigationList Syntax

navigationlist widgetName {
  item itemName (caption: 'Caption', action: ACTION_TYPE)
}

Supported Actions:

  • action: save_changes - Save changes
  • action: cancel_changes - Cancel changes
  • action: close_page - Close current page
  • action: delete - Delete object
  • action: microflow Module.MicroflowName - Call microflow
  • action: microflow Module.MicroflowName(Param: $value) - Call microflow with parameters
  • action: show_page Module.PageName - Navigate to page
  • action: show_page Module.PageName(Param: $value) - Navigate with parameters
  • A show_page argument must be the context object, and there has to BE one. Mendix takes the page argument from the enclosing data widget, so the only spellings that mean anything are $currentObject or the name of the variable that widget is bound to (datasource: $Customer → (Customer: $Customer) is fine). Naming any other variable is refused as MDL-PAGEARG01 — it used to be accepted and silently opened the page with the context object anyway.
  • Outside a data widget the same rule leaves nothing at all, so a button sitting on the page itself (or in a plain container/layoutgrid) may pass no argument — not a page parameter, not $currentObject, not a literal. There is no context object there for Mendix to infer, and the page opens with nothing: mxbuild reports CE1571 per parameter of the target page, and a page whose parameters are optional would simply show the wrong data. MDL-PAGEARG01 refuses that too (mendixlabs/mxcli#1029). To open a parameterised page from such a button, call a microflow that does show page Module.Page(Param: $value) — that path wires the arguments properly.

Handling Circular Dependencies

When a navigation snippet references pages (via show_page) and those pages reference the snippet (via snippetcall), you have a circular dependency. Use the placeholder pattern:

Creation Order

  1. Create placeholder snippet first (before pages)
  2. Create all pages (which reference the snippet via SNIPPETCALL)
  3. Replace snippet with full content (which can now reference existing pages)

Example Pattern

-- Step 1: Create placeholder snippet (pages can reference this)
create snippet Module.NavigationMenu
{
  layoutgrid navGrid {
    row row1 {
      column col1 (desktopwidth: 12) {
        dynamictext loading (content: 'Loading...')
      }
    }
  }
}
/

-- Step 2: Create all pages (they reference the snippet via SNIPPETCALL)
create page Module.Customer_NewEdit
(
  params: { $Customer: Module.Customer },
  title: 'Edit Customer',
  layout: Atlas_Core.PopupLayout
)
{
  -- ... page content with SNIPPETCALL navMenu (Snippet: Module.NavigationMenu)
}
/

create page Module.Customer_Overview
(
  title: 'Customer Overview',
  layout: Atlas_Core.Atlas_Default
)
{
  -- ... page content with SNIPPETCALL navMenu (Snippet: Module.NavigationMenu)
}
/

-- Step 3: Fill in the snippet with real content (pages now exist)
-- Use CREATE OR MODIFY (preserves the snippet's ID → page bindings stay valid)
-- Do NOT use CREATE OR REPLACE — that would assign a new ID and break existing page references
create or modify snippet Module.NavigationMenu
{
  layoutgrid navGrid {
    row row1 {
      column col1 (desktopwidth: 12) {
        actionbutton btnCustomers (caption: 'Customers', action: show_page Module.Customer_Overview)
      }
    }
  }
}
/

Key Points

  • The placeholder snippet must exist before pages are created (for snippetcall to resolve)
  • Use create or modify snippet for the fill-in step — it preserves the snippet's UUID so pages that already reference it remain valid
  • Do not use create or replace snippet — that deletes the placeholder and creates a fresh UUID, silently breaking every page that references the old one
  • Page references in the final snippet resolve correctly because pages already exist

See Resolve Forward References for the full pattern including page→page and microflow→page cases, declaration ordering rules, and the choice between CREATE OR MODIFY and ALTER SNIPPET.

Related Skills

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
122
Forks
49
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
overview-pages
Source
github.com/mendixlabs/mxcli