Skip to content
Open
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
1 change: 1 addition & 0 deletions .claude/skills/mendix/write-microflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ commit $Product;

**Rules:**
- `@annotation` before an activity attaches the note to that activity
- `@annotation` before activity-binding metadata such as `@position`, `@caption`, `@color`, `@excluded`, or `@anchor` stays free-floating when later metadata binds the following activity
- `@annotation` at the end (no following activity) creates a free-floating note
- Escape single quotes by doubling: `@annotation 'Don''t forget'`
- `@position` always appears in DESCRIBE output; `@caption` only when custom; `@color` only when not Default
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 @@ -231,6 +231,7 @@ authentication basic, session
| Caption | `@caption 'text'` | Custom caption (before activity) |
| Color | `@color Green` | Background color (before activity) |
| Annotation | `@annotation 'text'` | Visual note attached to next activity |
| Free annotation | `@annotation 'text'` before `@position(...)` | Free-floating visual note preserved by order |
| IF | `if condition then ... [else ...] end if;` | |
| LOOP | `loop $item in $list begin ... end loop;` | FOR EACH over list |
| WHILE | `while condition begin ... end while;` | Condition-based loop |
Expand Down
59 changes: 59 additions & 0 deletions docs/11-proposals/PROPOSAL_microflow_free_annotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Microflow Free Annotation

Status: Draft

## Summary

Add explicit round-trip support for free-floating microflow annotations that are
serialized next to an activity in MDL but are not visually attached to that
activity in the Mendix model.

## Motivation

Mendix stores some visual notes as standalone annotations. During `describe`,
those notes can appear immediately before an activity because the activity is
the next stable textual anchor. If the parser treats every preceding
`@annotation` as activity metadata, `exec` rewrites the note into an attached
annotation flow and changes the diagram.

## Semantics

`@annotation 'text'` is treated as a free annotation when both conditions hold:

1. It appears before any activity-binding metadata for the same statement.
2. A later activity-binding annotation follows before the activity statement.

Activity-binding metadata is currently `@position`, `@caption`, `@color`,
`@excluded`, or `@anchor`.

Example:

```mdl
@annotation 'section header'
@position(100, 200)
log info node 'Audit' 'starting';
```

The note remains free-floating. By contrast, an annotation after `@position` is
still attached to the activity:

```mdl
@position(100, 200)
@annotation 'activity note'
log info node 'Audit' 'starting';
```

## Tests And Examples

- `mdl-examples/doctype-tests/free_annotation.test.mdl` documents the supported
syntax.
- Parser tests cover both order-sensitive cases.
- Builder tests verify that the free annotation is emitted as a standalone
annotation and not attached to the activity.

## Open Questions

- Should free annotation binding use textual order only, or should it also
consider visual proximity in the microflow diagram?
- Should MDL grow an explicit keyword for free annotations to avoid relying on
order-sensitive disambiguation?
1 change: 1 addition & 0 deletions docs/11-proposals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ BSON schema Registry ◄──── multi-version Support
|----------|--------|---------|------------|
| [MDL Syntax Improvements v1](PROPOSAL_mdl_syntax_improvements.md) | Draft | Go-style assignment, C-style braces, fluent list APIs | — |
| [MDL Syntax Improvements v2](PROPOSAL_mdl_syntax_improvements_v2.md) | Proposed | Consolidated v2: unified variable declaration, C-style braces, fluent list ops | Syntax Improvements v1 |
| [Microflow Free Annotation](PROPOSAL_microflow_free_annotation.md) | Draft | Order-sensitive `@annotation` handling for free-floating visual notes in microflows | — |
| [Page Syntax V2](PROPOSAL_page_syntax_v2.md) | Superseded | Page/widget syntax with `{}` blocks and `->` binding. Superseded by V3 (archived) | — |
| [Page Styling Support](page-styling-support.md) | Partial | CSS classes, inline styles, dynamic classes, design properties. Phase 1 (Class/Style) done | — |
| [Page Composition](proposal_page_composition.md) | Proposed | Fragment definitions and ALTER PAGE for partial page editing | Page Syntax V2, Page Styling |
Expand Down
14 changes: 14 additions & 0 deletions mdl-examples/doctype-tests/free_annotation.test.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
create microflow SampleAnnotations.ACT_FreeAnnotation ()
returns Void
begin
@annotation 'section header'
@position(100, 200)
log info node 'Sample' 'start';

@position(300, 200)
@annotation 'activity note'
log info node 'Sample' 'attached';

return;
end;
/
14 changes: 8 additions & 6 deletions mdl/ast/ast_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,14 @@ type FlowAnchors struct {
// ActivityAnnotations holds metadata annotations for microflow activities.
// These are emitted as @position, @caption, @color, @annotation, @excluded, @anchor lines in MDL.
type ActivityAnnotations struct {
Position *Position // @position(x, y)
Caption string // @caption 'text'
Color string // @color Green
AnnotationText string // @annotation 'text'
Excluded bool // @excluded
Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement
Position *Position // @position(x, y)
Caption string // @caption 'text'
Color string // @color Green
AnnotationText string // @annotation 'text'
FreeAnnotation string // @annotation 'text' before @position/@anchor, kept free-floating
FreeAnnotations []string // Multiple free-floating @annotation lines in source order
Excluded bool // @excluded
Anchor *FlowAnchors // @anchor(from: X, to: Y) — anchors of the flow leaving this statement

// Split-specific anchors for IF statements. When the statement is not an
// IF these remain nil. The grammar accepts them on IfStmt only:
Expand Down
22 changes: 22 additions & 0 deletions mdl/executor/cmd_microflows_builder_annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ func (fb *flowBuilder) mergeStatementAnnotations(stmt ast.MicroflowStatement) {
if ann.AnnotationText != "" {
fb.pendingAnnotations.AnnotationText = ann.AnnotationText
}
if texts := freeAnnotationTexts(ann); len(texts) > 0 {
fb.pendingAnnotations.FreeAnnotations = append(fb.pendingAnnotations.FreeAnnotations, texts...)
if fb.pendingAnnotations.FreeAnnotation == "" {
fb.pendingAnnotations.FreeAnnotation = texts[0]
}
}
if ann.Anchor != nil {
fb.pendingAnnotations.Anchor = ann.Anchor
}
Expand Down Expand Up @@ -269,3 +275,19 @@ func (fb *flowBuilder) attachFreeAnnotation(text string) {
}
fb.objects = append(fb.objects, annotation)
}

func freeAnnotationTexts(ann *ast.ActivityAnnotations) []string {
if ann == nil {
return nil
}
texts := append([]string(nil), ann.FreeAnnotations...)
if ann.FreeAnnotation != "" {
for _, text := range texts {
if text == ann.FreeAnnotation {
return texts
}
}
texts = append(texts, ann.FreeAnnotation)
}
return texts
}
57 changes: 57 additions & 0 deletions mdl/executor/cmd_microflows_builder_annotations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,60 @@ func TestInheritanceSplitCaptionApplied(t *testing.T) {
t.Errorf("inheritance split caption: got %q, want %q", split.Caption, "Customer type?")
}
}

func TestFreeAnnotationBeforePositionStaysUnattached(t *testing.T) {
body := []ast.MicroflowStatement{
&ast.LogStmt{
Level: ast.LogInfo,
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "message"},
Annotations: &ast.ActivityAnnotations{
FreeAnnotation: "free synthetic note",
Position: &ast.Position{X: 120, Y: 240},
},
},
}

fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
oc := fb.buildFlowGraph(body, nil)

freeAnnotations := collectFreeAnnotations(oc)
if len(freeAnnotations) != 1 || freeAnnotations[0] != "free synthetic note" {
t.Fatalf("free annotations = %#v, want one free note", freeAnnotations)
}

attached := buildAnnotationsByTarget(oc)
for activityID, captions := range attached {
for _, caption := range captions {
if caption == "free synthetic note" {
t.Fatalf("free note was attached to activity %s", activityID)
}
}
}
}

func TestMultipleFreeAnnotationsBeforePositionStayUnattached(t *testing.T) {
body := []ast.MicroflowStatement{
&ast.LogStmt{
Level: ast.LogInfo,
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "message"},
Annotations: &ast.ActivityAnnotations{
FreeAnnotations: []string{"first free note", "second free note"},
Position: &ast.Position{X: 120, Y: 240},
},
},
}

fb := &flowBuilder{posX: 100, posY: 100, spacing: HorizontalSpacing}
oc := fb.buildFlowGraph(body, nil)

freeAnnotations := collectFreeAnnotations(oc)
want := []string{"first free note", "second free note"}
if len(freeAnnotations) != len(want) {
t.Fatalf("free annotations = %#v, want %#v", freeAnnotations, want)
}
for i, wantText := range want {
if freeAnnotations[i] != wantText {
t.Fatalf("free annotation %d = %q, want %q", i, freeAnnotations[i], wantText)
}
}
}
10 changes: 10 additions & 0 deletions mdl/executor/cmd_microflows_builder_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ func (fb *flowBuilder) buildFlowGraph(stmts []ast.MicroflowStatement, returns *a

// Handle leftover pending annotations (free-floating annotation text)
if fb.pendingAnnotations != nil {
for _, text := range freeAnnotationTexts(fb.pendingAnnotations) {
fb.attachFreeAnnotation(text)
}
if fb.pendingAnnotations.AnnotationText != "" {
fb.attachFreeAnnotation(fb.pendingAnnotations.AnnotationText)
}
Expand Down Expand Up @@ -178,6 +181,13 @@ func (fb *flowBuilder) addStatement(stmt ast.MicroflowStatement) model.ID {
fb.posX = fb.pendingAnnotations.Position.X
fb.posY = fb.pendingAnnotations.Position.Y
}
if fb.pendingAnnotations != nil {
for _, text := range freeAnnotationTexts(fb.pendingAnnotations) {
fb.attachFreeAnnotation(text)
}
fb.pendingAnnotations.FreeAnnotation = ""
fb.pendingAnnotations.FreeAnnotations = nil
}

switch s := stmt.(type) {
case *ast.DeclareStmt:
Expand Down
18 changes: 2 additions & 16 deletions mdl/executor/cmd_microflows_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,7 @@ func describeMicroflow(ctx *ExecContext, name ast.QualifiedName) error {
// Generate activities
if targetMf.ObjectCollection != nil && len(targetMf.ObjectCollection.Objects) > 0 {
activityLines := formatMicroflowActivities(ctx, targetMf, entityNames, microflowNames)
freeAnnots := collectFreeAnnotations(targetMf.ObjectCollection)
if len(freeAnnots) > 0 && len(activityLines) > 0 {
prefix := make([]string, 0, len(freeAnnots))
for _, text := range freeAnnots {
prefix = append(prefix, fmt.Sprintf("@annotation %s", mdlQuote(text)))
}
activityLines = append(prefix, activityLines...)
}
activityLines = prependFreeAnnotationLines(targetMf.ObjectCollection, activityLines)
for _, line := range activityLines {
lines = append(lines, " "+line)
}
Expand Down Expand Up @@ -541,14 +534,7 @@ func renderMicroflowMDL(
} else {
activityLines = formatMicroflowActivities(ctx, mf, entityNames, microflowNames)
}
freeAnnots := collectFreeAnnotations(mf.ObjectCollection)
if len(freeAnnots) > 0 && len(activityLines) > 0 {
prefix := make([]string, 0, len(freeAnnots))
for _, text := range freeAnnots {
prefix = append(prefix, fmt.Sprintf("@annotation %s", mdlQuote(text)))
}
activityLines = append(prefix, activityLines...)
}
activityLines = prependFreeAnnotationLines(mf.ObjectCollection, activityLines)
for _, line := range activityLines {
lines = append(lines, " "+line)
}
Expand Down
13 changes: 13 additions & 0 deletions mdl/executor/cmd_microflows_show_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ func collectFreeAnnotations(oc *microflows.MicroflowObjectCollection) []string {
return result
}

func prependFreeAnnotationLines(oc *microflows.MicroflowObjectCollection, activityLines []string) []string {
freeAnnots := collectFreeAnnotations(oc)
if len(freeAnnots) == 0 || len(activityLines) == 0 {
return activityLines
}

prefix := make([]string, 0, len(freeAnnots))
for _, text := range freeAnnots {
prefix = append(prefix, fmt.Sprintf("@annotation %s", mdlQuote(text)))
}
return append(prefix, activityLines...)
}

// anchorSideKeyword returns the MDL keyword (top/right/bottom/left) for a
// connection-index value. Returns "" for unknown values.
func anchorSideKeyword(idx int) string {
Expand Down
44 changes: 44 additions & 0 deletions mdl/executor/cmd_microflows_show_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,50 @@ func TestEmitObjectAnnotations_EscapesMultilineText(t *testing.T) {
}
}

func TestPrependFreeAnnotationLines_ModelAnnotationsStayFree(t *testing.T) {
oc := &microflows.MicroflowObjectCollection{
Objects: []microflows.MicroflowObject{
&microflows.Annotation{
BaseMicroflowObject: mkObj("free-note"),
Caption: "free synthetic note",
},
&microflows.Annotation{
BaseMicroflowObject: mkObj("attached-note"),
Caption: "attached synthetic note",
},
},
AnnotationFlows: []*microflows.AnnotationFlow{
{
BaseElement: model.BaseElement{ID: mkID("annotation-flow")},
OriginID: mkID("attached-note"),
DestinationID: mkID("activity"),
},
},
}

activityLines := []string{
"@position(100, 200)",
"@annotation 'attached synthetic note'",
"log info 'Synthetic' 'message';",
}

gotLines := prependFreeAnnotationLines(oc, activityLines)
got := strings.Join(gotLines, "\n")

want := strings.Join([]string{
"@annotation 'free synthetic note'",
"@position(100, 200)",
"@annotation 'attached synthetic note'",
"log info 'Synthetic' 'message';",
}, "\n")
if got != want {
t.Fatalf("free annotation describe output mismatch\nwant:\n%s\n\ngot:\n%s", want, got)
}
if strings.Count(got, "attached synthetic note") != 1 {
t.Fatalf("attached annotation was emitted as free too:\n%s", got)
}
}

// =============================================================================
// formatErrorHandlingSuffix
// =============================================================================
Expand Down
Loading
Loading