diff --git a/.claude/skills/mendix/custom-widgets.md b/.claude/skills/mendix/custom-widgets.md index 543c7e13..74a8e877 100644 --- a/.claude/skills/mendix/custom-widgets.md +++ b/.claude/skills/mendix/custom-widgets.md @@ -1,6 +1,6 @@ --- name: mendix-custom-widgets -description: Use when writing MDL for GALLERY, COMBOBOX, or third-party pluggable widgets in CREATE PAGE / ALTER PAGE statements. Covers built-in widget syntax, child slots (TEMPLATE/FILTER), adding new custom widgets via .def.json, and engine internals. +description: Use when writing MDL for GALLERY, COMBOBOX, or third-party pluggable widgets in CREATE PAGE / ALTER PAGE statements. Covers built-in widget syntax, child slots (TEMPLATE/FILTER), real-time MPK discovery for project widgets, and adding custom widgets via .def.json. --- # Custom & Pluggable Widgets in MDL @@ -59,7 +59,46 @@ combobox cmbCustomer ( - `CaptionAttribute` is the display attribute on the **target** entity - In association mode, mapping order matters: DataSource must resolve before Association (sets entityContext) -## Adding a Third-Party Widget +## Project Widgets (Real-Time Discovery) + +**No extraction step required.** When `mxcli` runs a `CREATE PAGE` command against a project, it automatically scans `/widgets/*.mpk` and makes every widget available by its derived MDL name — the last dot-segment of the widget ID, lowercased. + +``` +com.vendor.widget.web.MySlider.MySlider → MDL keyword: MYSLIDER +com.example.QRScanner → MDL keyword: QRSCANNER +``` + +### Using a project widget in MDL + +```sql +-- No widget init needed. Just use the widget by its derived name. +create page Module.MyPage (layout: Atlas_Default) { + dataview dv (entity: Module.Product) { + MYSLIDER slider1 (datasource: database Module.Product, attribute: Price) + } +} +``` + +If the widget has a `datasource`, `attribute`, `association`, or `widgets` property in its XML, those are auto-mapped. For properties that need custom mapping (actions, expressions, textTemplates), see the extraction workflow below. + +### Checking what's available + +```bash +# Lists all widgets: built-in + auto-discovered from project MPKs +mxcli widget list -p App.mpr +``` + +### When auto-discovery isn't enough + +Extract a `.def.json` only if you need to: +- Override the auto-inferred property mappings +- Add support for `action`, `expression`, or `textTemplate` properties +- Control the MDL keyword (the derived name doesn't match what you want) +- Share a definition globally across projects (`~/.mxcli/widgets/`) + +--- + +## Customizing a Widget (.def.json Workflow) ### Step 1 -- Extract .def.json from .mpk @@ -291,15 +330,16 @@ project/widgets/*.mpk -> FindMPK(projectDir, widgetID) -> ParseMPK() This reduces CE0463 errors from widget version drift without requiring manual template re-extraction. -### 3-Tier Registry +### 4-Tier Registry | Priority | Location | Scope | |----------|----------|-------| -| 1 (highest) | `/.mxcli/widgets/*.def.json` | Project | +| 1 (highest) | `/.mxcli/widgets/*.def.json` | Project (hand-crafted) | | 2 | `~/.mxcli/widgets/*.def.json` | Global (user) | -| 3 (lowest) | `sdk/widgets/definitions/*.def.json` (embedded) | Built-in | +| 3 | `sdk/widgets/definitions/*.def.json` (embedded) | Built-in | +| 4 (lowest) | `/widgets/*.mpk` (real-time) | Project (auto-derived) | -Higher priority definitions override lower ones with the same MDL name (case-insensitive). +Higher priority definitions override lower ones. Real-time MPK derivation only activates when no definition exists at tiers 1–3. The MDL name derived from an MPK (lowercase last ID segment) is used as the key; if a built-in or hand-crafted definition uses the same name, the MPK entry is silently skipped. ## Verify & Debug @@ -322,7 +362,7 @@ mxcli bson dump -p App.mpr --type page --object "Module.PageName" --format ndsl | Mistake | Fix | |---------|-----| | CE0463 after page creation | Template version mismatch -- extract fresh template from Studio Pro MPR, or ensure .mpk augmentation picks up new properties | -| Widget not recognized | Check `mxcli widget list`; .def.json must be in `.mxcli/widgets/` with `.def.json` extension | +| Widget not recognized | Run `mxcli widget list -p App.mpr` — project widgets are auto-discovered from `widgets/*.mpk`; if still missing, the .mpk may not exist or the widget ID differs from expected | | TEMPLATE content missing | Widget needs `childSlots` entry with `"mdlContainer": "template"` | | Association COMBOBOX shows enum behavior | Add `datasource` to trigger association mode (`hasDataSource` condition) | | Association mapping fails | Ensure DataSource mapping appears **before** Association mapping in the array | @@ -334,10 +374,10 @@ mxcli bson dump -p App.mpr --type page --object "Module.PageName" --format ndsl | File | Purpose | |------|---------| | `mdl/executor/widget_engine.go` | PluggableWidgetEngine, 6 operations, Build() pipeline | -| `mdl/executor/widget_registry.go` | 3-tier WidgetRegistry, definition validation | +| `mdl/executor/widget_registry.go` | 4-tier WidgetRegistry: `SetProjectDir` triggers real-time MPK scan; `Get`/`GetByWidgetID` fall back to MPK derivation on miss | | `sdk/widgets/loader.go` | Template loading, ID remapping, MPK augmentation | | `sdk/widgets/mpk/mpk.go` | .mpk ZIP parsing, XML property extraction | -| `cmd/mxcli/cmd_widget.go` | `mxcli widget extract/list` CLI commands | +| `cmd/mxcli/cmd_widget.go` | `mxcli widget extract/list/init` CLI commands (`init` is now optional; use `--force` to overwrite) | | `sdk/widgets/definitions/*.def.json` | Built-in widget definitions (ComboBox, Gallery) | | `sdk/widgets/templates/mendix-11.6/*.json` | Embedded BSON templates | | `mdl/executor/cmd_pages_builder_input.go` | `updateWidgetPropertyValue()` -- TypePointer matching | diff --git a/cmd/mxcli/cmd_extract_templates.go b/cmd/mxcli/cmd_extract_templates.go index 7011e78e..e74fdfd6 100644 --- a/cmd/mxcli/cmd_extract_templates.go +++ b/cmd/mxcli/cmd_extract_templates.go @@ -21,9 +21,9 @@ var extractTemplatesCmd = &cobra.Command{ Long: `Extract pluggable widget type definitions from a Mendix project and save them as JSON templates for use in mxcli. -This command searches for CustomWidgets in the project and extracts -their type definitions, which can then be embedded in mxcli for -consistent widget creation across projects. +This command scans all pages and snippets in the project for pluggable +widgets and extracts their type definitions. The resulting JSON files can +be embedded in mxcli for consistent widget creation across projects. Example: mxcli extract-templates -p app.mpr -o templates/mendix-11.6/`, @@ -38,8 +38,8 @@ func init() { rootCmd.AddCommand(extractTemplatesCmd) } -// WidgetTemplate is the JSON structure for a widget template file. -type WidgetTemplate struct { +// extractedWidgetTemplate is the JSON structure for a widget template file. +type extractedWidgetTemplate struct { WidgetID string `json:"widgetId"` Name string `json:"name"` Version string `json:"version"` @@ -52,93 +52,72 @@ func runExtractTemplates(cmd *cobra.Command, args []string) error { projectPath, _ := cmd.Flags().GetString("project") outputDir, _ := cmd.Flags().GetString("output") - // Open the project reader, err := mpr.Open(projectPath) if err != nil { return fmt.Errorf("failed to open project: %w", err) } defer reader.Close() - // Get Mendix version version, _ := reader.GetMendixVersion() fmt.Printf("Extracting templates from Mendix %s project\n", version) - // Create output directory if err := os.MkdirAll(outputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) } - // Widget IDs to extract - widgetIDs := []struct { - id string - filename string - name string - }{ - {"com.mendix.widget.web.combobox.Combobox", "combobox.json", "Combo box"}, - {"com.mendix.widget.web.gallery.Gallery", "gallery.json", "Gallery"}, - {"com.mendix.widget.web.datagrid.Datagrid", "datagrid.json", "Data grid 2"}, - {"com.mendix.widget.web.datagridtextfilter.DatagridTextFilter", "datagrid-text-filter.json", "Text filter"}, - {"com.mendix.widget.web.datagriddatefilter.DatagridDateFilter", "datagrid-date-filter.json", "Date filter"}, - {"com.mendix.widget.web.datagriddropdownfilter.DatagridDropdownFilter", "datagrid-dropdown-filter.json", "Dropdown filter"}, - {"com.mendix.widget.web.datagridnumberfilter.DatagridNumberFilter", "datagrid-number-filter.json", "Number filter"}, - {"com.mendix.widget.web.image.Image", "image.json", "Image"}, + widgets, err := reader.ListAllCustomWidgetTypes() + if err != nil { + return fmt.Errorf("failed to scan project for widget types: %w", err) + } + if len(widgets) == 0 { + fmt.Println("No pluggable widget types found in this project.") + return nil } + fmt.Printf("Found %d widget type(s)\n", len(widgets)) extracted := 0 - for _, w := range widgetIDs { - rawWidget, err := reader.FindCustomWidgetType(w.id) - if err != nil { - fmt.Printf(" [SKIP] %s: %v\n", w.name, err) - continue - } - if rawWidget == nil { - fmt.Printf(" [SKIP] %s: not found in project\n", w.name) - continue - } - - // Convert BSON to JSON-compatible map - typeMap, err := bsonDToMap(rawWidget.RawType) + for _, w := range widgets { + typeMap, err := bsonDToMap(w.RawType) if err != nil { - fmt.Printf(" [SKIP] %s: failed to convert type BSON: %v\n", w.name, err) + fmt.Printf(" [SKIP] %s: failed to convert type BSON: %v\n", w.WidgetID, err) continue } var objectMap map[string]any - if rawWidget.RawObject != nil { - objectMap, err = bsonDToMap(rawWidget.RawObject) + if w.RawObject != nil { + objectMap, err = bsonDToMap(w.RawObject) if err != nil { - fmt.Printf(" [SKIP] %s: failed to convert object BSON: %v\n", w.name, err) + fmt.Printf(" [SKIP] %s: failed to convert object BSON: %v\n", w.WidgetID, err) continue } } - template := WidgetTemplate{ - WidgetID: w.id, - Name: w.name, + template := extractedWidgetTemplate{ + WidgetID: w.WidgetID, Version: version, - ExtractedFrom: rawWidget.UnitID, + ExtractedFrom: w.UnitID, Type: typeMap, Object: objectMap, } - // Write to file - outPath := filepath.Join(outputDir, w.filename) + filename := filenameFromWidgetID(w.WidgetID) + outPath := filepath.Join(outputDir, filename) data, err := json.MarshalIndent(template, "", " ") if err != nil { - fmt.Printf(" [SKIP] %s: failed to marshal JSON: %v\n", w.name, err) + fmt.Printf(" [SKIP] %s: failed to marshal JSON: %v\n", w.WidgetID, err) continue } if err := os.WriteFile(outPath, data, 0644); err != nil { - fmt.Printf(" [SKIP] %s: failed to write file: %v\n", w.name, err) + fmt.Printf(" [SKIP] %s: failed to write file: %v\n", w.WidgetID, err) continue } - fmt.Printf(" [OK] %s -> %s\n", w.name, w.filename) + fmt.Printf(" [OK] %s -> %s\n", w.WidgetID, filename) extracted++ } - fmt.Printf("\nExtracted %d widget templates to %s\n", extracted, outputDir) + fmt.Printf("\nExtracted %d/%d widget templates to %s\n", extracted, len(widgets), outputDir) return nil } @@ -167,7 +146,6 @@ func convertBsonValue(v any) any { } return arr case primitive.Binary: - // Convert binary IDs to hex strings return fmt.Sprintf("%x", val.Data) case []byte: return fmt.Sprintf("%x", val) @@ -176,12 +154,11 @@ func convertBsonValue(v any) any { } } -// filenameFromWidgetID generates a filename from a widget ID. +// filenameFromWidgetID generates a kebab-case filename from a widget ID. +// e.g. "com.mendix.widget.web.combobox.Combobox" -> "combobox.json" func filenameFromWidgetID(widgetID string) string { - // Extract the last part after the last dot parts := strings.Split(widgetID, ".") name := parts[len(parts)-1] - // Convert camelCase to kebab-case var result strings.Builder for i, r := range name { if i > 0 && r >= 'A' && r <= 'Z' { diff --git a/cmd/mxcli/cmd_widget.go b/cmd/mxcli/cmd_widget.go index 9be761ea..29806a18 100644 --- a/cmd/mxcli/cmd_widget.go +++ b/cmd/mxcli/cmd_widget.go @@ -46,15 +46,25 @@ var widgetListCmd = &cobra.Command{ var widgetInitCmd = &cobra.Command{ Use: "init", - Short: "Extract definitions for all project widgets", - Long: `Scan the project's widgets/ directory, extract .def.json for each .mpk, -and generate skill documentation in .claude/skills/widgets/. + Short: "Dump widget definitions for inspection or customization", + Long: `Scan the project's widgets/ directory and write .def.json files to +.mxcli/widgets/ for each .mpk. Also writes markdown reference docs to +.claude/skills/widgets/. -This enables CREATE PAGE to use any project widget via the pluggable engine. +Note: mxcli widget init is no longer required for CREATE PAGE to work. +Widget definitions are derived automatically at runtime from the project's +widgets/*.mpk files. Run this command only when you need to inspect or +hand-edit a widget's property mappings. + +Existing .def.json files are skipped unless --force is given. +Built-in widget definitions (e.g. GALLERY, COMBOBOX) are always skipped; +they are not derived from project .mpk files. + +Note: --force will overwrite any hand-edits you have made to .def.json files. Requires --project (-p) to locate the project's widgets/ directory.`, Example: ` mxcli widget init -p /path/to/app.mpr - mxcli widget init -p app.mpr`, + mxcli widget init -p app.mpr --force`, RunE: runWidgetInit, } @@ -73,6 +83,7 @@ func init() { widgetInitCmd.Flags().StringP("project", "p", "", "Path to .mpr project file") widgetInitCmd.MarkFlagRequired("project") + widgetInitCmd.Flags().Bool("force", false, "Overwrite existing .def.json files") widgetDocsCmd.Flags().StringP("project", "p", "", "Path to .mpr project file") widgetDocsCmd.MarkFlagRequired("project") @@ -219,6 +230,7 @@ func generateDefJSON(mpkDef *mpk.WidgetDefinition, mdlName string) *executor.Wid func runWidgetInit(cmd *cobra.Command, args []string) error { projectPath, _ := cmd.Flags().GetString("project") + force, _ := cmd.Flags().GetBool("force") projectDir := filepath.Dir(projectPath) widgetsDir := filepath.Join(projectDir, "widgets") outputDir := filepath.Join(projectDir, ".mxcli", "widgets") @@ -242,49 +254,53 @@ func runWidgetInit(cmd *cobra.Command, args []string) error { var extracted, skipped int for _, mpkPath := range matches { - mpkDef, err := mpk.ParseMPK(mpkPath) + defs, err := mpk.ParseAll(mpkPath) if err != nil { log.Printf("warning: skipping %s: %v", filepath.Base(mpkPath), err) skipped++ continue } - mdlName := deriveMDLName(mpkDef.ID) - filename := strings.ToLower(mdlName) + ".def.json" - outPath := filepath.Join(outputDir, filename) + for _, mpkDef := range defs { + mdlName := deriveMDLName(mpkDef.ID) + filename := strings.ToLower(mdlName) + ".def.json" + outPath := filepath.Join(outputDir, filename) + + // Skip widgets that have hand-crafted built-in definitions (e.g., COMBOBOX, GALLERY) + if builtinRegistry != nil { + if _, ok := builtinRegistry.GetByWidgetID(mpkDef.ID); ok { + skipped++ + continue + } + } + + // Skip if already exists on disk (unless --force) + if !force { + if _, err := os.Stat(outPath); err == nil { + skipped++ + continue + } + } - // Skip widgets that have hand-crafted built-in definitions (e.g., COMBOBOX, GALLERY) - if builtinRegistry != nil { - if _, ok := builtinRegistry.GetByWidgetID(mpkDef.ID); ok { + defJSON := generateDefJSON(mpkDef, mdlName) + data, err := json.MarshalIndent(defJSON, "", " ") + if err != nil { + log.Printf("warning: skipping %s: %v", mpkDef.ID, err) skipped++ continue } - } - - // Skip if already exists on disk - if _, err := os.Stat(outPath); err == nil { - skipped++ - continue - } - - defJSON := generateDefJSON(mpkDef, mdlName) - data, err := json.MarshalIndent(defJSON, "", " ") - if err != nil { - log.Printf("warning: skipping %s: %v", mpkDef.ID, err) - skipped++ - continue - } - data = append(data, '\n') + data = append(data, '\n') - if err := os.WriteFile(outPath, data, 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", outPath, err) - } - kind := "custom" - if mpkDef.IsPluggable { - kind = "pluggable" + if err := os.WriteFile(outPath, data, 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", outPath, err) + } + kind := "custom" + if mpkDef.IsPluggable { + kind = "pluggable" + } + fmt.Printf(" %-12s %-20s %s\n", kind, mdlName, mpkDef.ID) + extracted++ } - fmt.Printf(" %-12s %-20s %s\n", kind, mdlName, mpkDef.ID) - extracted++ } fmt.Printf("\nExtracted: %d, Skipped: %d (existing or unparseable)\n", extracted, skipped) @@ -321,29 +337,31 @@ func generateWidgetDocs(projectDir string) error { var indexEntries []string for _, mpkPath := range matches { - mpkDef, err := mpk.ParseMPK(mpkPath) + defs, err := mpk.ParseAll(mpkPath) if err != nil { continue } - mdlName := deriveMDLName(mpkDef.ID) - filename := strings.ToLower(mdlName) + ".md" - outPath := filepath.Join(docsDir, filename) + for _, mpkDef := range defs { + mdlName := deriveMDLName(mpkDef.ID) + filename := strings.ToLower(mdlName) + ".md" + outPath := filepath.Join(docsDir, filename) - doc := generateWidgetDoc(mpkDef, mdlName) + doc := generateWidgetDoc(mpkDef, mdlName) - if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil { - log.Printf("warning: failed to write %s: %v", filename, err) - continue - } + if err := os.WriteFile(outPath, []byte(doc), 0644); err != nil { + log.Printf("warning: failed to write %s: %v", filename, err) + continue + } - kind := "CUSTOMWIDGET" - if mpkDef.IsPluggable { - kind = "PLUGGABLEWIDGET" + kind := "CUSTOMWIDGET" + if mpkDef.IsPluggable { + kind = "PLUGGABLEWIDGET" + } + indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |", + kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties))) + generated++ } - indexEntries = append(indexEntries, fmt.Sprintf("| `%s` | %s | `%s` | %s | %d |", - kind, mdlName, mpkDef.ID, mpkDef.Name, len(mpkDef.Properties))) - generated++ } // Write index diff --git a/docs/superpowers/plans/2026-05-08-mpk-template-derivation.md b/docs/superpowers/plans/2026-05-08-mpk-template-derivation.md new file mode 100644 index 00000000..bc4868f6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-mpk-template-derivation.md @@ -0,0 +1,973 @@ +# MPK-Derived Widget Templates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Any pluggable widget whose `.mpk` file is in `project/widgets/` can be used in MDL `create page` commands without a pre-built embedded template — mxcli derives the template from the MPK at runtime, transparently. + +**Architecture:** New `sdk/widgets/generate.go` builds a `WidgetTemplate` (type + object shells) from a `mpk.WidgetDefinition` using existing `createPropertyPair` helpers from `augment.go`. A new internal `getOrGenerateTemplate` helper in `loader.go` adds a fallback path after the embedded-template cache miss, calling `mpk.FindMPK` → `mpk.ParseMPKForWidget` → `GenerateFromMPK`, with a session-scoped `sync.Map` to avoid re-parsing on every call. + +**Tech Stack:** Go, `sdk/widgets/mpk` (existing ZIP parser), `sdk/widgets/augment.go` (existing property builders), `go.mongodb.org/mongo-driver/bson` + +--- + +## File Map + +| Action | File | What changes | +|--------|------|-------------| +| Modify | `sdk/widgets/mpk/mpk.go` | Fix `FindMPK` and add `ParseMPKForWidget` for multi-widget MPKs | +| Modify | `sdk/widgets/mpk/mpk_test.go` | Tests for multi-widget MPK handling | +| **Create** | `sdk/widgets/generate.go` | `GenerateFromMPK(def) *WidgetTemplate` | +| **Create** | `sdk/widgets/generate_test.go` | Unit tests for generate.go | +| Modify | `sdk/widgets/loader.go` | `getOrGenerateTemplate`, `generatedCache`, wire into BSON functions | +| Modify | `sdk/widgets/augment_test.go` | Add loader fallback integration test | + +--- + +## Task 1: Fix multi-widget MPK support + +**Context:** `FindMPK` calls `getWidgetIDFromMPK`, which reads only `WidgetFiles[0]` from `package.xml`. `ParseMPK` also reads only the first widget file. `CrusherWidgets.mpk` bundles 5 widgets (CavitySelector, CrusherSlider, etc.) — only the first would be discoverable. This must be fixed before anything else. + +**Files:** +- Modify: `sdk/widgets/mpk/mpk.go` +- Modify: `sdk/widgets/mpk/mpk_test.go` + +- [ ] **Step 1.1: Write failing test for multi-widget FindMPK** + +In `sdk/widgets/mpk/mpk_test.go`, add after the existing helpers: + +```go +func TestFindMPK_MultiWidget(t *testing.T) { + mpkPath := filepath.Join("..", "..", "..", "D:/gh/posui/mendix/CrusherCopilot/widgets/CrusherWidgets.mpk") + if _, err := os.Stat(mpkPath); err != nil { + t.Skip("CrusherWidgets.mpk not available") + } + projectDir := filepath.Dir(mpkPath) // .../CrusherCopilot/widgets/ — wrong, use parent + projectDir = filepath.Join("..", "..", "..", "D:/gh/posui/mendix/CrusherCopilot") + if _, err := os.Stat(projectDir); err != nil { + t.Skip("CrusherCopilot project not available") + } + + widgets := []string{ + "com.mendix.widget.custom.CavitySelector.CavitySelector", + "com.mendix.widget.custom.CrusherSlider.CrusherSlider", + "com.mendix.widget.custom.PredictionBadge.PredictionBadge", + "com.mendix.widget.custom.CrusherSimCanvas.CrusherSimCanvas", + "com.mendix.widget.custom.HeatmapViz.HeatmapViz", + } + for _, wid := range widgets { + found, err := FindMPK(projectDir, wid) + if err != nil { + t.Fatalf("FindMPK(%q): %v", wid, err) + } + if found == "" { + t.Errorf("FindMPK(%q): expected MPK path, got empty string", wid) + } + } +} + +func TestParseMPKForWidget_MultiWidget(t *testing.T) { + mpkPath := filepath.Join("..", "..", "..", "D:/gh/posui/mendix/CrusherCopilot/widgets/CrusherWidgets.mpk") + if _, err := os.Stat(mpkPath); err != nil { + t.Skip("CrusherWidgets.mpk not available") + } + + widgetID := "com.mendix.widget.custom.CavitySelector.CavitySelector" + def, err := ParseMPKForWidget(mpkPath, widgetID) + if err != nil { + t.Fatalf("ParseMPKForWidget: %v", err) + } + if def == nil { + t.Fatal("ParseMPKForWidget: got nil definition") + } + if def.ID != widgetID { + t.Errorf("ID = %q, want %q", def.ID, widgetID) + } + if len(def.Properties) == 0 { + t.Error("expected at least one property") + } +} +``` + +- [ ] **Step 1.2: Run tests to confirm they fail** + +```bash +cd D:/gh/mxcli +go test ./sdk/widgets/mpk/ -run "TestFindMPK_MultiWidget|TestParseMPKForWidget_MultiWidget" -v +``` + +Expected: FAIL — `ParseMPKForWidget` is undefined; `FindMPK` returns empty for all but the first widget. + +- [ ] **Step 1.3: Add `getWidgetIDsFromMPK` (plural) and `ParseMPKForWidget` to mpk.go** + +In `sdk/widgets/mpk/mpk.go`, replace `getWidgetIDFromMPK` with a plural version and add `ParseMPKForWidget`. + +**Replace** the existing `getWidgetIDFromMPK` function (lines ~330-406) with: + +```go +// getWidgetIDsFromMPK returns ALL widget IDs declared in an .mpk package.xml. +// Multi-widget MPKs (e.g. CrusherWidgets.mpk) list multiple entries. +func getWidgetIDsFromMPK(mpkPath string) ([]string, error) { + r, err := zip.OpenReader(mpkPath) + if err != nil { + return nil, err + } + defer r.Close() + + var widgetFilePaths []string + var totalExtracted uint64 + for _, f := range r.File { + if f.Name == "package.xml" { + if f.UncompressedSize64 > maxFileSize { + return nil, fmt.Errorf("package.xml exceeds max file size (%d > %d)", f.UncompressedSize64, maxFileSize) + } + rc, err := f.Open() + if err != nil { + return nil, err + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, err + } + totalExtracted += uint64(len(data)) + if totalExtracted > maxTotalSize { + return nil, fmt.Errorf("total extracted size exceeds limit") + } + var pkg xmlPackage + if err := xml.Unmarshal(data, &pkg); err != nil { + return nil, err + } + for _, wf := range pkg.ClientModule.WidgetFiles { + widgetFilePaths = append(widgetFilePaths, wf.Path) + } + break + } + } + + var ids []string + for _, path := range widgetFilePaths { + for _, f := range r.File { + if f.Name == path { + if f.UncompressedSize64 > maxFileSize { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + totalExtracted += uint64(len(data)) + if totalExtracted > maxTotalSize { + return ids, fmt.Errorf("total extracted size exceeds limit") + } + var widget struct { + ID string `xml:"id,attr"` + } + if err := xml.Unmarshal(data, &widget); err != nil { + continue + } + if widget.ID != "" { + ids = append(ids, widget.ID) + } + } + } + } + return ids, nil +} + +// ParseMPKForWidget parses the widget XML for a specific widgetID from an .mpk file. +// Unlike ParseMPK (which reads the first widget), this finds the widget file whose +// parsed ID matches widgetID — needed for multi-widget .mpk packages. +func ParseMPKForWidget(mpkPath string, widgetID string) (*WidgetDefinition, error) { + // Check definition cache (ParseMPK stores by mpkPath; we use widgetID as key here) + defCacheLock.RLock() + if def, ok := defCache[mpkPath+"\x00"+widgetID]; ok { + defCacheLock.RUnlock() + return def, nil + } + defCacheLock.RUnlock() + + r, err := zip.OpenReader(mpkPath) + if err != nil { + return nil, fmt.Errorf("failed to open mpk: %w", err) + } + defer r.Close() + + // Parse package.xml to get all widget file paths and version + var pkg xmlPackage + var version string + var totalExtracted uint64 + for _, f := range r.File { + if f.Name == "package.xml" { + if f.UncompressedSize64 > maxFileSize { + return nil, fmt.Errorf("package.xml exceeds max size") + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("open package.xml: %w", err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("read package.xml: %w", err) + } + totalExtracted += uint64(len(data)) + if err := xml.Unmarshal(data, &pkg); err != nil { + return nil, fmt.Errorf("parse package.xml: %w", err) + } + version = pkg.ClientModule.Version + break + } + } + + // Try each widget file until we find one with a matching ID + for _, wf := range pkg.ClientModule.WidgetFiles { + for _, f := range r.File { + if f.Name != wf.Path { + continue + } + if f.UncompressedSize64 > maxFileSize { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + totalExtracted += uint64(len(data)) + if totalExtracted > maxTotalSize { + return nil, fmt.Errorf("total extracted size exceeds limit") + } + + var widget xmlWidget + if err := xml.Unmarshal(data, &widget); err != nil { + continue + } + if widget.ID != widgetID { + continue + } + + // Found the matching widget — build definition + def := buildDefinition(&widget, version) + + cacheKey := mpkPath + "\x00" + widgetID + defCacheLock.Lock() + defCache[cacheKey] = def + defCacheLock.Unlock() + return def, nil + } + } + + return nil, nil // widget not found in this MPK +} +``` + +Note: `buildDefinition` is a helper that contains the repeated definition-building logic. Extract it from `ParseMPK` as shown in Step 1.4. + +- [ ] **Step 1.4: Refactor ParseMPK to share definition-building logic** + +In `sdk/widgets/mpk/mpk.go`, extract the widget-to-definition building from `ParseMPK` into a private `buildDefinition` function. Replace the block in `ParseMPK` starting from `def := &WidgetDefinition{...}` through the end: + +Find in `ParseMPK` the block that creates `def` and returns it. Extract it as: + +```go +// buildDefinition constructs a WidgetDefinition from a parsed xmlWidget and version string. +func buildDefinition(widget *xmlWidget, version string) *WidgetDefinition { + def := &WidgetDefinition{ + ID: widget.ID, + Name: widget.Name, + Version: version, + IsPluggable: widget.PluginWidget == "true", + } + for _, group := range widget.PropertyGroups { + collectProps(def, group, "") + } + return def +} +``` + +Then in `ParseMPK`, replace the definition-building code with: +```go +def := buildDefinition(&widget, version) +``` + +(The existing `collectProps` function in mpk.go is unchanged.) + +- [ ] **Step 1.5: Update `FindMPK` to use `getWidgetIDsFromMPK`** + +In `sdk/widgets/mpk/mpk.go`, in the `FindMPK` function, find the loop body where `getWidgetIDFromMPK` is called: + +```go +for _, mpkPath := range matches { + wid, err := getWidgetIDFromMPK(mpkPath) + if err != nil { + continue // Skip unparseable files + } + if wid != "" { + dirMap[wid] = mpkPath + } +} +``` + +Replace with: + +```go +for _, mpkPath := range matches { + wids, err := getWidgetIDsFromMPK(mpkPath) + if err != nil { + continue // Skip unparseable files + } + for _, wid := range wids { + if wid != "" { + dirMap[wid] = mpkPath + } + } +} +``` + +- [ ] **Step 1.6: Run tests to confirm they pass** + +```bash +cd D:/gh/mxcli +go test ./sdk/widgets/mpk/ -run "TestFindMPK_MultiWidget|TestParseMPKForWidget_MultiWidget" -v +``` + +Expected: PASS (or SKIP if CrusherWidgets.mpk not present). + +Also verify existing mpk tests still pass: +```bash +go test ./sdk/widgets/mpk/ -v +``` + +Expected: All PASS (or SKIP for external file tests). + +- [ ] **Step 1.7: Commit** + +```bash +cd D:/gh/mxcli +git add sdk/widgets/mpk/mpk.go sdk/widgets/mpk/mpk_test.go +git commit -m "fix: support multi-widget MPK files in FindMPK and ParseMPKForWidget" +``` + +--- + +## Task 2: Write generate.go (TDD) + +**Context:** `GenerateFromMPK` builds a `WidgetTemplate` outer shell (`CustomWidgetType` + `WidgetObject`) and populates it by calling the existing `createPropertyPair` / `xmlTypeToBSONType` helpers from `augment.go`. All `$ID` values use `placeholderID()` — `loader.go`'s `collectIDs` will remap them to real UUIDs before BSON serialisation. + +**Files:** +- Create: `sdk/widgets/generate_test.go` +- Create: `sdk/widgets/generate.go` + +- [ ] **Step 2.1: Write failing tests** + +Create `sdk/widgets/generate_test.go`: + +```go +// SPDX-License-Identifier: Apache-2.0 + +package widgets + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +func TestGenerateFromMPK_BasicTypes(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Version: "1.0.0", + Properties: []mpk.PropertyDef{ + {Key: "label", Type: "string", Caption: "Label"}, + {Key: "enabled", Type: "boolean", Caption: "Enabled", DefaultValue: "true"}, + {Key: "count", Type: "integer", Caption: "Count", DefaultValue: "0"}, + {Key: "value", Type: "expression", Caption: "Value"}, + {Key: "attr", Type: "attribute", Caption: "Attribute"}, + }, + } + + tmpl := GenerateFromMPK(def) + + if tmpl == nil { + t.Fatal("GenerateFromMPK returned nil") + } + if tmpl.WidgetID != def.ID { + t.Errorf("WidgetID = %q, want %q", tmpl.WidgetID, def.ID) + } + if tmpl.Name != def.Name { + t.Errorf("Name = %q, want %q", tmpl.Name, def.Name) + } + if tmpl.Version != def.Version { + t.Errorf("Version = %q, want %q", tmpl.Version, def.Version) + } + if tmpl.Type == nil { + t.Fatal("Type is nil") + } + if tmpl.Object == nil { + t.Fatal("Object is nil") + } + + // type.$Type must be CustomWidgets$CustomWidgetType + if got := tmpl.Type["$Type"]; got != "CustomWidgets$CustomWidgetType" { + t.Errorf("Type.$Type = %v, want CustomWidgets$CustomWidgetType", got) + } + + // ObjectType must exist with PropertyTypes + objType, ok := tmpl.Type["ObjectType"].(map[string]any) + if !ok { + t.Fatal("Type.ObjectType missing or wrong type") + } + propTypes, ok := objType["PropertyTypes"].([]any) + if !ok { + t.Fatal("ObjectType.PropertyTypes missing or wrong type") + } + // First element is the Mendix array version marker (float64(2)) + nonMarkerPropTypes := 0 + for _, pt := range propTypes { + if _, isFloat := pt.(float64); !isFloat { + nonMarkerPropTypes++ + } + } + if nonMarkerPropTypes != 5 { + t.Errorf("PropertyTypes count = %d, want 5", nonMarkerPropTypes) + } + + // Object Properties must match PropertyTypes count + objProps, ok := tmpl.Object["Properties"].([]any) + if !ok { + t.Fatal("Object.Properties missing or wrong type") + } + nonMarkerProps := 0 + for _, p := range objProps { + if _, isFloat := p.(float64); !isFloat { + nonMarkerProps++ + } + } + if nonMarkerProps != 5 { + t.Errorf("Properties count = %d, want 5", nonMarkerProps) + } +} + +func TestGenerateFromMPK_TypePointerCrossReference(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + {Key: "mode", Type: "enumeration", Caption: "Mode", DefaultValue: "fast"}, + }, + } + + tmpl := GenerateFromMPK(def) + + // Collect PropertyType $IDs + objType := tmpl.Type["ObjectType"].(map[string]any) + propTypes := objType["PropertyTypes"].([]any) + var ptID string + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + if ptMap["PropertyKey"] == "mode" { + ptID = ptMap["$ID"].(string) + break + } + } + if ptID == "" { + t.Fatal("PropertyType for 'mode' not found") + } + + // Find Property and verify TypePointer → PropertyType.$ID + objProps := tmpl.Object["Properties"].([]any) + var propTypePointer string + for _, p := range objProps { + pMap, ok := p.(map[string]any) + if !ok { + continue + } + propTypePointer = pMap["TypePointer"].(string) + break + } + if propTypePointer != ptID { + t.Errorf("Property.TypePointer = %q, want PropertyType.$ID %q", propTypePointer, ptID) + } +} + +func TestGenerateFromMPK_NestedObject(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + { + Key: "columns", + Type: "object", + Caption: "Columns", + IsList: true, + Children: []mpk.PropertyDef{ + {Key: "header", Type: "string", Caption: "Header"}, + {Key: "attr", Type: "attribute", Caption: "Attribute"}, + }, + }, + }, + } + + tmpl := GenerateFromMPK(def) + + objType := tmpl.Type["ObjectType"].(map[string]any) + propTypes := objType["PropertyTypes"].([]any) + var columnsPT map[string]any + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + if ptMap["PropertyKey"] == "columns" { + columnsPT = ptMap + break + } + } + if columnsPT == nil { + t.Fatal("columns PropertyType not found") + } + + vt, ok := columnsPT["ValueType"].(map[string]any) + if !ok { + t.Fatal("ValueType missing on columns property") + } + nestedObjType, ok := vt["ObjectType"].(map[string]any) + if !ok { + t.Fatal("ObjectType missing on columns ValueType — nested object not built") + } + nestedPTs, ok := nestedObjType["PropertyTypes"].([]any) + if !ok { + t.Fatal("nested PropertyTypes missing") + } + nestedCount := 0 + for _, npt := range nestedPTs { + if _, isFloat := npt.(float64); !isFloat { + nestedCount++ + } + } + if nestedCount != 2 { + t.Errorf("nested PropertyTypes count = %d, want 2", nestedCount) + } +} + +func TestGenerateFromMPK_UnknownTypeSkipped(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + {Key: "good", Type: "string", Caption: "Good"}, + {Key: "bad", Type: "unknownXmlType", Caption: "Bad"}, + }, + } + + tmpl := GenerateFromMPK(def) + + objType := tmpl.Type["ObjectType"].(map[string]any) + propTypes := objType["PropertyTypes"].([]any) + count := 0 + for _, pt := range propTypes { + if _, isFloat := pt.(float64); !isFloat { + count++ + } + } + if count != 1 { + t.Errorf("PropertyTypes count = %d, want 1 (unknown type skipped)", count) + } +} + +func TestGenerateFromMPK_PlaceholderIDsRemapped(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + {Key: "label", Type: "string", Caption: "Label"}, + }, + } + + tmpl := GenerateFromMPK(def) + + // After GetTemplateFullBSON, no aa000000-prefix IDs should remain + callCount := 0 + idGen := func() string { + callCount++ + return strings.Repeat("f", 32) // deterministic non-placeholder IDs + } + + // Temporarily insert into template cache so GetTemplateFullBSON finds it + templateCacheLock.Lock() + templateCache["com.example.Widget"] = tmpl + templateCacheLock.Unlock() + defer func() { + templateCacheLock.Lock() + delete(templateCache, "com.example.Widget") + templateCacheLock.Unlock() + }() + + bsonType, bsonObj, _, _, err := GetTemplateFullBSON("com.example.Widget", idGen, "") + if err != nil { + t.Fatalf("GetTemplateFullBSON: %v", err) + } + if containsPlaceholderID(bsonType) { + t.Error("placeholder IDs leaked in bsonType") + } + if bsonObj != nil && containsPlaceholderID(bsonObj) { + t.Error("placeholder IDs leaked in bsonObj") + } +} +``` + +- [ ] **Step 2.2: Run tests to confirm they fail** + +```bash +cd D:/gh/mxcli +go test ./sdk/widgets/ -run "TestGenerateFromMPK" -v +``` + +Expected: FAIL — `GenerateFromMPK` is undefined. + +- [ ] **Step 2.3: Implement generate.go** + +Create `sdk/widgets/generate.go`: + +```go +// SPDX-License-Identifier: Apache-2.0 + +package widgets + +import "github.com/mendixlabs/mxcli/sdk/widgets/mpk" + +// GenerateFromMPK builds a complete WidgetTemplate from a parsed MPK WidgetDefinition. +// All $IDs are placeholder IDs (aa000000... prefix); loader.go's collectIDs remaps them +// to real UUIDs before BSON serialisation — matching the lifecycle of embedded templates. +// System properties (Label, Visibility, Editability) are not added; Studio Pro injects them. +func GenerateFromMPK(def *mpk.WidgetDefinition) *WidgetTemplate { + typeID := placeholderID() + objTypeID := placeholderID() + + var propTypes []any + var objProps []any + propTypes = append(propTypes, float64(2)) // Mendix array version marker + objProps = append(objProps, float64(2)) + + for _, p := range def.Properties { + bsonType := xmlTypeToBSONType(p.Type) + if bsonType == "" { + continue // unknown XML type — skip silently + } + pt, prop := createPropertyPair(p, bsonType) + if pt != nil { + propTypes = append(propTypes, pt) + } + if prop != nil { + objProps = append(objProps, prop) + } + } + + typeMap := map[string]any{ + "$ID": typeID, + "$Type": "CustomWidgets$CustomWidgetType", + "WidgetId": def.ID, + "ObjectType": map[string]any{ + "$ID": objTypeID, + "$Type": "CustomWidgets$WidgetObjectType", + "PropertyTypes": propTypes, + }, + } + + objectMap := map[string]any{ + "$ID": placeholderID(), + "$Type": "CustomWidgets$WidgetObject", + "TypePointer": typeID, + "Properties": objProps, + } + + return &WidgetTemplate{ + WidgetID: def.ID, + Name: def.Name, + Version: def.Version, + Type: typeMap, + Object: objectMap, + } +} +``` + +- [ ] **Step 2.4: Run tests to confirm they pass** + +```bash +cd D:/gh/mxcli +go test ./sdk/widgets/ -run "TestGenerateFromMPK" -v +``` + +Expected: All 5 tests PASS. + +- [ ] **Step 2.5: Commit** + +```bash +cd D:/gh/mxcli +git add sdk/widgets/generate.go sdk/widgets/generate_test.go +git commit -m "feat: add GenerateFromMPK — build WidgetTemplate from MPK definition" +``` + +--- + +## Task 3: Wire loader.go — transparent fallback + +**Context:** `GetTemplateBSON` and `GetTemplateFullBSON` both call `GetTemplate(widgetID)` which returns `nil, nil` on miss. We add a new `getOrGenerateTemplate(widgetID, projectPath)` helper that adds the MPK fallback path after the embedded-template miss. Both BSON functions switch to calling this helper instead. + +**Files:** +- Modify: `sdk/widgets/loader.go` + +- [ ] **Step 3.1: Write failing integration test** + +In `sdk/widgets/augment_test.go`, add at the bottom: + +```go +func TestGetTemplateFullBSON_FallsBackToMPK(t *testing.T) { + crusherProjectPath := "D:/gh/posui/mendix/CrusherCopilot" + if _, err := os.Stat(crusherProjectPath); err != nil { + t.Skip("CrusherCopilot project not available") + } + + widgetID := "com.mendix.widget.custom.CavitySelector.CavitySelector" + + // Must not be in embedded templates + embedded, err := GetTemplate(widgetID) + if err != nil { + t.Fatalf("GetTemplate: %v", err) + } + if embedded != nil { + t.Skip("CavitySelector is now an embedded template — test no longer relevant") + } + + idGen := func() string { return strings.Repeat("a", 32) } + bsonType, bsonObj, propTypeIDs, _, err := GetTemplateFullBSON(widgetID, idGen, crusherProjectPath) + if err != nil { + t.Fatalf("GetTemplateFullBSON fallback: %v", err) + } + if bsonType == nil { + t.Fatal("expected non-nil bsonType from MPK fallback") + } + if bsonObj == nil { + t.Fatal("expected non-nil bsonObj from MPK fallback") + } + if len(propTypeIDs) == 0 { + t.Error("expected non-empty propTypeIDs") + } +} +``` + +Also add the `os` and `strings` imports if missing from `augment_test.go`. + +- [ ] **Step 3.2: Run test to confirm it fails** + +```bash +cd D:/gh/mxcli +go test ./sdk/widgets/ -run "TestGetTemplateFullBSON_FallsBackToMPK" -v +``` + +Expected: FAIL — `GetTemplateFullBSON` returns `nil` for unknown widget (no fallback yet). + +- [ ] **Step 3.3: Add generatedCache and getOrGenerateTemplate to loader.go** + +In `sdk/widgets/loader.go`, add after the `templateCache` declaration block (around line 117): + +```go +// generatedCache stores MPK-derived templates for the session lifetime. +// Key: widgetID string. Value: *WidgetTemplate (placeholder IDs, not yet remapped). +var generatedCache sync.Map +``` + +Then add the new helper function. Insert before `GetTemplateBSON` (around line 208): + +```go +// getOrGenerateTemplate returns a WidgetTemplate for widgetID. It checks the embedded +// template cache first, then falls back to deriving a template from the project's .mpk +// widget file. Returns nil, nil when the widget is unknown and no MPK is available. +func getOrGenerateTemplate(widgetID, projectPath string) (*WidgetTemplate, error) { + // 1. Embedded templates (existing path) + if tmpl, err := GetTemplate(widgetID); err != nil || tmpl != nil { + return tmpl, err + } + + // 2. Session cache of previously generated templates + if cached, ok := generatedCache.Load(widgetID); ok { + return cached.(*WidgetTemplate), nil + } + + // 3. Derive from MPK in project/widgets/ + if projectPath == "" { + return nil, nil + } + mpkPath, err := mpk.FindMPK(projectPath, widgetID) + if err != nil { + return nil, fmt.Errorf("widget %q: scan MPK directory: %w", widgetID, err) + } + if mpkPath == "" { + return nil, nil // no MPK found — caller treats nil as "widget unknown" + } + def, err := mpk.ParseMPKForWidget(mpkPath, widgetID) + if err != nil { + return nil, fmt.Errorf("widget %q: parse MPK: %w", widgetID, err) + } + if def == nil { + return nil, nil + } + tmpl := GenerateFromMPK(def) + generatedCache.Store(widgetID, tmpl) + return tmpl, nil +} +``` + +- [ ] **Step 3.4: Update GetTemplateBSON to use getOrGenerateTemplate** + +In `sdk/widgets/loader.go`, in `GetTemplateBSON` (around line 208), replace: + +```go +tmpl, err := GetTemplate(widgetID) +if err != nil { + return nil, nil, err +} +if tmpl == nil { + return nil, nil, nil +} +``` + +with: + +```go +tmpl, err := getOrGenerateTemplate(widgetID, projectPath) +if err != nil { + return nil, nil, err +} +if tmpl == nil { + return nil, nil, nil +} +``` + +- [ ] **Step 3.5: Update GetTemplateFullBSON to use getOrGenerateTemplate** + +In `sdk/widgets/loader.go`, in `GetTemplateFullBSON` (around line 240), replace: + +```go +tmpl, err := GetTemplate(widgetID) +if err != nil { + return nil, nil, nil, "", err +} +if tmpl == nil { + return nil, nil, nil, "", nil +} +``` + +with: + +```go +tmpl, err := getOrGenerateTemplate(widgetID, projectPath) +if err != nil { + return nil, nil, nil, "", err +} +if tmpl == nil { + return nil, nil, nil, "", nil +} +``` + +- [ ] **Step 3.6: Run tests** + +```bash +cd D:/gh/mxcli +go test ./sdk/widgets/ -run "TestGetTemplateFullBSON_FallsBackToMPK" -v +``` + +Expected: PASS (or SKIP if CrusherCopilot not present). + +Run full widget package tests to check for regressions: +```bash +go test ./sdk/widgets/... -v 2>&1 | tail -20 +``` + +Expected: All PASS (file-dependent tests may SKIP). + +- [ ] **Step 3.7: Build and verify no compilation errors** + +```bash +cd D:/gh/mxcli +make build +``` + +Expected: Build succeeds with no errors. + +- [ ] **Step 3.8: Commit** + +```bash +cd D:/gh/mxcli +git add sdk/widgets/loader.go sdk/widgets/augment_test.go +git commit -m "feat: fall back to MPK-derived template for unknown pluggable widgets" +``` + +--- + +## Task 4: End-to-end verification + +**Context:** Confirm that a `create page` MDL command referencing a Crusher widget (which has no embedded template) succeeds without error and produces an MPR that Studio Pro accepts without CE0463. + +**Files:** No new files — run existing CLI against CrusherCopilot. + +- [ ] **Step 4.1: Run make test** + +```bash +cd D:/gh/mxcli +make test +``` + +Expected: All tests pass. + +- [ ] **Step 4.2: Smoke-test CLI against CrusherCopilot** + +```bash +cd D:/gh/mxcli +./bin/mxcli -p "D:/gh/posui/mendix/CrusherCopilot/CrusherCopilot.mpr" -c \ + "show widgets" +``` + +Expected: Output lists widget types including CavitySelector and other Crusher widgets (resolved from MPK). + +- [ ] **Step 4.3: Commit if any fixups needed, otherwise done** + +If the smoke test surfaces a BSON issue, debug using `.claude/skills/debug-bson.md`. +Final commit if any fixups were made: + +```bash +git add -p +git commit -m "fix: " +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** Multi-widget MPK (Task 1) ✓ · generate.go (Task 2) ✓ · loader.go fallback (Task 3) ✓ · session cache (Task 3) ✓ · performance fallback deferred (out of scope per spec) ✓ · 5 named tests (Tasks 2+3) ✓ · acceptance criterion end-to-end (Task 4) ✓ +- **Type consistency:** `GenerateFromMPK` signature is `(def *mpk.WidgetDefinition) *WidgetTemplate` — used identically in generate.go and loader.go · `ParseMPKForWidget` signature `(mpkPath, widgetID string) (*WidgetDefinition, error)` — consistent across mpk.go and loader.go · `FindMPK` returns `(string, error)` — error correctly handled in `getOrGenerateTemplate` +- **No placeholders:** All steps contain complete code. No TBDs. diff --git a/docs/superpowers/plans/2026-05-09-widget-realtime-registry.md b/docs/superpowers/plans/2026-05-09-widget-realtime-registry.md new file mode 100644 index 00000000..007debd2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-widget-realtime-registry.md @@ -0,0 +1,630 @@ +# Widget Real-Time Registry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `mxcli widget init` pre-extraction requirement with real-time MPK-derived widget definitions, so that `CREATE PAGE` works with project widgets without any setup step. + +**Architecture:** `WidgetRegistry` gains a `SetProjectDir` method that pre-scans `widgets/*.mpk` (building a lightweight `mdlName→widgetID` map), then falls back to on-demand MPK parsing when `Get`/`GetByWidgetID` misses. Results are cached in the registry maps so the second lookup is O(1). Hand-crafted `.def.json` overrides still take priority. + +**Tech Stack:** Go, `archive/zip` (stdlib), `sdk/widgets/mpk` (already imported nowhere in `mdl/executor/` — adding it is safe, no circular dependency). + +--- + +## File Map + +| File | Change | +|------|--------| +| `mdl/executor/widget_registry.go` | Add fields, methods, MPK fallback in Get/GetByWidgetID | +| `mdl/executor/widget_registry_mpk_test.go` | New test file (4 tests) | +| `mdl/executor/cmd_pages_builder.go` | Wire `SetProjectDir` into `initPluggableEngine` | +| `cmd/mxcli/cmd_widget.go` | Add `--force` flag, update help text, remove skip-if-exists guard | + +--- + +## Task 1: Add MPK fallback to WidgetRegistry + +**Files:** +- Modify: `mdl/executor/widget_registry.go` +- Create: `mdl/executor/widget_registry_mpk_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `mdl/executor/widget_registry_mpk_test.go`: + +```go +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +// writeMiniMPK creates a minimal .mpk ZIP in widgetsDir with the given widget ID. +func writeMiniMPK(t *testing.T, widgetsDir, widgetID string) { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + // last segment of ID becomes the XML file name + parts := strings.Split(widgetID, ".") + xmlName := parts[len(parts)-1] + ".xml" + + pkg, _ := w.Create("package.xml") + fmt.Fprintf(pkg, + ``, + xmlName, + ) + + wxml, _ := w.Create(xmlName) + fmt.Fprintf(wxml, + `Test`, + widgetID, + ) + + w.Close() + + mpkPath := filepath.Join(widgetsDir, parts[len(parts)-1]+".mpk") + if err := os.WriteFile(mpkPath, buf.Bytes(), 0644); err != nil { + t.Fatalf("write MPK: %v", err) + } +} + +func TestRegistryMPKFallbackGet(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + writeMiniMPK(t, widgetsDir, "com.test.widget.Testwidget") + + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("NewWidgetRegistry: %v", err) + } + if err := reg.SetProjectDir(dir); err != nil { + t.Fatalf("SetProjectDir: %v", err) + } + + def, ok := reg.Get("TESTWIDGET") + if !ok { + t.Fatal("expected TESTWIDGET via MPK fallback, got not-found") + } + if def.WidgetID != "com.test.widget.Testwidget" { + t.Errorf("WidgetID = %q, want com.test.widget.Testwidget", def.WidgetID) + } + if def.WidgetKind != "pluggable" { + t.Errorf("WidgetKind = %q, want pluggable", def.WidgetKind) + } +} + +func TestRegistryMPKFallbackGetByWidgetID(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + writeMiniMPK(t, widgetsDir, "com.test.widget.Testwidget") + + reg, _ := NewWidgetRegistry() + reg.SetProjectDir(dir) + + def, ok := reg.GetByWidgetID("com.test.widget.Testwidget") + if !ok { + t.Fatal("expected GetByWidgetID fallback to find widget") + } + if def.MDLName != "testwidget" { + t.Errorf("MDLName = %q, want testwidget", def.MDLName) + } +} + +func TestRegistryMPKFallbackCached(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + writeMiniMPK(t, widgetsDir, "com.test.widget.Testwidget") + + reg, _ := NewWidgetRegistry() + reg.SetProjectDir(dir) + + def1, ok1 := reg.Get("TESTWIDGET") + if !ok1 { + t.Fatal("first lookup failed") + } + def2, ok2 := reg.Get("TESTWIDGET") + if !ok2 { + t.Fatal("second lookup failed") + } + if def1 != def2 { + t.Error("expected same pointer on second lookup (in-registry cache)") + } +} + +func TestRegistryMPKFallbackSkipsBuiltins(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + // Write a fake MPK that would derive "GALLERY" — should be ignored + writeMiniMPK(t, widgetsDir, "com.mendix.widget.web.gallery.Gallery") + + reg, _ := NewWidgetRegistry() + builtinDef, builtinOK := reg.Get("GALLERY") + + reg.SetProjectDir(dir) + + afterDef, afterOK := reg.Get("GALLERY") + if builtinOK { + // Gallery is a builtin: SetProjectDir must not replace it + if afterDef != builtinDef { + t.Error("SetProjectDir must not override a built-in definition") + } + } else { + // Gallery not a builtin in this build: MPK fallback is fine + if !afterOK { + t.Error("expected GALLERY to be found after SetProjectDir") + } + } +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd D:/gh/mxcli +go test ./mdl/executor/ -run "TestRegistryMPK" -v 2>&1 | head -40 +``` + +Expected: compilation error (`SetProjectDir` undefined) — confirms test is wired in correctly. + +- [ ] **Step 3: Implement the changes in `widget_registry.go`** + +**3a. Add the `mpk` import** — edit the import block at the top of the file: + +```go +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" + "github.com/mendixlabs/mxcli/sdk/widgets/definitions" + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) +``` + +**3b. Add two fields to `WidgetRegistry`** — replace the struct definition (lines 18-22): + +```go +type WidgetRegistry struct { + byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName + byWidgetID map[string]*WidgetDefinition // keyed by widgetId + knownOperations map[string]bool // operations accepted during validation + projectDir string // project root for MPK fallback + mpkNameMap map[string]string // uppercase MDLName → widgetID (pre-scan) +} +``` + +**3c. Initialise `mpkNameMap` in `NewWidgetRegistryWithOps`** — after `byWidgetID` (line 67 area): + +```go +reg := &WidgetRegistry{ + byMDLName: make(map[string]*WidgetDefinition), + byWidgetID: make(map[string]*WidgetDefinition), + knownOperations: ops, + mpkNameMap: make(map[string]string), +} +``` + +**3d. Modify `Get`** — replace lines 107-110: + +```go +// Get returns a widget definition by MDL name (case-insensitive). +// Falls back to real-time MPK derivation when SetProjectDir has been called. +func (r *WidgetRegistry) Get(mdlName string) (*WidgetDefinition, bool) { + name := strings.ToUpper(mdlName) + if def, ok := r.byMDLName[name]; ok { + return def, ok + } + if r.projectDir == "" { + return nil, false + } + widgetID, ok := r.mpkNameMap[name] + if !ok { + return nil, false + } + def, err := r.deriveFromMPK(widgetID) + if err != nil { + log.Printf("warning: MPK fallback for %s: %v", name, err) + return nil, false + } + if def == nil { + return nil, false + } + r.byMDLName[strings.ToUpper(def.MDLName)] = def + r.byWidgetID[def.WidgetID] = def + return def, true +} +``` + +**3e. Modify `GetByWidgetID`** — replace lines 113-116: + +```go +// GetByWidgetID returns a widget definition by its full widget ID. +// Falls back to real-time MPK derivation when SetProjectDir has been called. +func (r *WidgetRegistry) GetByWidgetID(widgetID string) (*WidgetDefinition, bool) { + if def, ok := r.byWidgetID[widgetID]; ok { + return def, ok + } + if r.projectDir == "" { + return nil, false + } + def, err := r.deriveFromMPK(widgetID) + if err != nil { + log.Printf("warning: MPK fallback for widget ID %s: %v", widgetID, err) + return nil, false + } + if def == nil { + return nil, false + } + r.byMDLName[strings.ToUpper(def.MDLName)] = def + r.byWidgetID[def.WidgetID] = def + return def, true +} +``` + +**3f. Add new methods at the end of the file** (after `validateMappings`): + +```go +// SetProjectDir enables real-time MPK fallback for this registry. +// It pre-scans widgets/*.mpk to build a lightweight mdlName→widgetID map +// (used by Get), and stores the dir for on-demand full parsing (used by both +// Get and GetByWidgetID). Safe to call multiple times; last call wins. +func (r *WidgetRegistry) SetProjectDir(projectDir string) error { + r.projectDir = projectDir + r.mpkNameMap = make(map[string]string) + return r.preScanWidgets(projectDir) +} + +// preScanWidgets does a full parse of all widgets/*.mpk files (results are +// cached by the mpk package) and records which MDL names are available. +// Built-in and user-override names already in byMDLName are skipped. +func (r *WidgetRegistry) preScanWidgets(projectDir string) error { + widgetsDir := filepath.Join(projectDir, "widgets") + matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk")) + if err != nil { + return fmt.Errorf("scan widgets dir: %w", err) + } + for _, mpkPath := range matches { + defs, err := mpk.ParseAll(mpkPath) + if err != nil { + log.Printf("warning: widget pre-scan skipping %s: %v", filepath.Base(mpkPath), err) + continue + } + for _, d := range defs { + name := strings.ToUpper(lastIDSegment(d.ID)) + if _, exists := r.byMDLName[name]; exists { + continue // builtin or user-override wins + } + r.mpkNameMap[name] = d.ID + } + } + return nil +} + +// deriveFromMPK parses the MPK for widgetID and returns a WidgetDefinition. +// Returns nil, nil when the widget is not found in the project's widgets/. +func (r *WidgetRegistry) deriveFromMPK(widgetID string) (*WidgetDefinition, error) { + mpkPath, err := mpk.FindMPK(r.projectDir, widgetID) + if err != nil { + return nil, fmt.Errorf("find mpk for %s: %w", widgetID, err) + } + if mpkPath == "" { + return nil, nil + } + mpkDef, err := mpk.ParseMPKForWidget(mpkPath, widgetID) + if err != nil { + return nil, fmt.Errorf("parse mpk for %s: %w", widgetID, err) + } + if mpkDef == nil { + return nil, nil + } + return buildDefinitionFromMPK(mpkDef), nil +} + +// lastIDSegment returns the last dot-separated segment of a widget ID, lowercased. +// e.g. "com.mendix.widget.web.gallery.Gallery" → "gallery" +func lastIDSegment(widgetID string) string { + parts := strings.Split(widgetID, ".") + return strings.ToLower(parts[len(parts)-1]) +} + +// buildDefinitionFromMPK converts an mpk.WidgetDefinition to an executor +// WidgetDefinition using the same inference logic as widget extract/init. +func buildDefinitionFromMPK(mpkDef *mpk.WidgetDefinition) *WidgetDefinition { + mdlName := lastIDSegment(mpkDef.ID) + widgetKind := "custom" + if mpkDef.IsPluggable { + widgetKind = "pluggable" + } + def := &WidgetDefinition{ + WidgetID: mpkDef.ID, + MDLName: mdlName, + WidgetKind: widgetKind, + TemplateFile: mdlName + ".json", + DefaultEditable: "Always", + } + + var assocMappings []PropertyMapping + for _, p := range mpkDef.Properties { + switch p.Type { + case "widgets": + container := strings.ToUpper(p.Key) + if p.Key == "content" { + container = "TEMPLATE" + } + def.ChildSlots = append(def.ChildSlots, ChildSlotMapping{ + PropertyKey: p.Key, + MDLContainer: strings.ToLower(container), + Operation: "widgets", + }) + case "datasource": + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "DataSource", + Operation: "datasource", + }) + case "attribute": + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "Attribute", + Operation: "attribute", + }) + case "association": + assocMappings = append(assocMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "Association", + Operation: "association", + }) + case "selection": + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "Selection", + Operation: "selection", + Default: p.DefaultValue, + }) + case "boolean", "integer", "decimal", "string", "enumeration": + m := PropertyMapping{ + PropertyKey: p.Key, + Operation: "primitive", + } + if p.DefaultValue != "" { + m.Value = p.DefaultValue + } + def.PropertyMappings = append(def.PropertyMappings, m) + } + } + def.PropertyMappings = append(def.PropertyMappings, assocMappings...) + return def +} +``` + +- [ ] **Step 4: Run the tests** + +```bash +cd D:/gh/mxcli +go test ./mdl/executor/ -run "TestRegistryMPK" -v +``` + +Expected output: 4 tests PASS. + +- [ ] **Step 5: Run the full executor test suite** + +```bash +go test ./mdl/executor/ -timeout 60s +``` + +Expected: all tests pass (no regressions). + +- [ ] **Step 6: Commit** + +```bash +git add mdl/executor/widget_registry.go mdl/executor/widget_registry_mpk_test.go +git commit -m "feat: add real-time MPK fallback to WidgetRegistry" +``` + +--- + +## Task 2: Wire SetProjectDir into pageBuilder + +**Files:** +- Modify: `mdl/executor/cmd_pages_builder.go` lines 55-72 (`initPluggableEngine`) + +- [ ] **Step 1: Add `filepath` import to `cmd_pages_builder.go`** + +The file currently imports `context`, `fmt`, `log`, `strings` — check if `path/filepath` is already there. If not, add it to the import block: + +```go +import ( + "context" + "fmt" + "log" + "path/filepath" + "strings" + // ... rest unchanged +) +``` + +- [ ] **Step 2: Update `initPluggableEngine` to call `SetProjectDir`** + +Replace lines 55-72 with: + +```go +func (pb *pageBuilder) initPluggableEngine() { + if pb.pluggableEngine != nil || pb.pluggableEngineErr != nil { + return + } + registry, err := NewWidgetRegistry() + if err != nil { + pb.pluggableEngineErr = mdlerrors.NewBackend("widget registry init", err) + log.Printf("warning: %v", pb.pluggableEngineErr) + return + } + if pb.backend != nil { + if loadErr := registry.LoadUserDefinitions(pb.backend.Path()); loadErr != nil { + log.Printf("warning: loading user widget definitions: %v", loadErr) + } + projectDir := filepath.Dir(pb.backend.Path()) + if scanErr := registry.SetProjectDir(projectDir); scanErr != nil { + log.Printf("warning: widget pre-scan: %v", scanErr) + } + } + pb.widgetRegistry = registry + pb.pluggableEngine = NewPluggableWidgetEngine(pb.widgetBackend, pb) +} +``` + +- [ ] **Step 3: Build and test** + +```bash +cd D:/gh/mxcli +make build +go test ./mdl/executor/ -timeout 60s +``` + +Expected: build succeeds, tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add mdl/executor/cmd_pages_builder.go +git commit -m "feat: wire SetProjectDir into pageBuilder for runtime MPK fallback" +``` + +--- + +## Task 3: Update widget init command + +**Files:** +- Modify: `cmd/mxcli/cmd_widget.go` + +- [ ] **Step 1: Add `--force` flag and update help text** + +Replace the `widgetInitCmd` declaration (lines 47-59) with: + +```go +var widgetInitCmd = &cobra.Command{ + Use: "init", + Short: "Dump widget definitions for inspection or customization", + Long: `Scan the project's widgets/ directory and write .def.json files to +.mxcli/widgets/ for each .mpk. + +Note: mxcli widget init is no longer required for CREATE PAGE to work. +Widget definitions are derived automatically at runtime from the project's +widgets/*.mpk files. Run this command only when you need to inspect or +hand-edit a widget's property mappings. + +Existing .def.json files are skipped unless --force is given. + +Requires --project (-p) to locate the project's widgets/ directory.`, + Example: ` mxcli widget init -p /path/to/app.mpr + mxcli widget init -p app.mpr --force`, + RunE: runWidgetInit, +} +``` + +- [ ] **Step 2: Register the `--force` flag in `init()`** + +In the `init()` function (around line 68), add after `widgetInitCmd.MarkFlagRequired("project")`: + +```go +widgetInitCmd.Flags().Bool("force", false, "Overwrite existing .def.json files") +``` + +- [ ] **Step 3: Use the `--force` flag in `runWidgetInit`** + +In `runWidgetInit` (line 220), read the flag right after reading `projectPath`: + +```go +func runWidgetInit(cmd *cobra.Command, args []string) error { + projectPath, _ := cmd.Flags().GetString("project") + force, _ := cmd.Flags().GetBool("force") + // ... rest of function unchanged until the skip-if-exists block +``` + +Then replace the skip-if-exists block (lines 265-269): + +```go + // Skip if already exists on disk (unless --force) + if !force { + if _, err := os.Stat(outPath); err == nil { + skipped++ + continue + } + } +``` + +- [ ] **Step 4: Build and smoke test** + +```bash +cd D:/gh/mxcli +make build +./bin/mxcli widget init --help +``` + +Expected output: shows new help text and `--force` flag listed. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/mxcli/cmd_widget.go +git commit -m "feat: widget init adds --force flag, demotes to optional debug tool" +``` + +--- + +## Task 4: Full build and integration check + +- [ ] **Step 1: Full build and test** + +```bash +cd D:/gh/mxcli +make build && make test +``` + +Expected: all pass. + +- [ ] **Step 2: Verify `widget list` shows no regression** + +```bash +./bin/mxcli widget list +``` + +Expected: same built-in list as before (GALLERY, COMBOBOX, etc.). + +- [ ] **Step 3: Smoke test with a real project (if available)** + +If a `.mpr` project with a third-party widget is available: + +```bash +./bin/mxcli -p /path/to/app.mpr -c "create page TestP (layout: Atlas_Default) { dataview dv (entity: Module.Entity) { } }" +``` + +Then try referencing a project widget by its derived name. No `widget init` should be needed. + +- [ ] **Step 4: Final commit if any fixups were needed** + +```bash +git add -p +git commit -m "fix: widget registry MPK fallback fixups" +``` diff --git a/docs/superpowers/specs/2026-05-08-mpk-template-derivation-design.md b/docs/superpowers/specs/2026-05-08-mpk-template-derivation-design.md new file mode 100644 index 00000000..3226546b --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-mpk-template-derivation-design.md @@ -0,0 +1,184 @@ +# MPK-Derived Widget Templates + +**Date:** 2026-05-08 +**Status:** Approved +**Scope:** `sdk/widgets/` + +## Problem + +mxcli can only use pluggable widgets that have pre-built templates embedded in the binary (`sdk/widgets/templates/mendix-11.6/*.json`). Non-embedded widgets (e.g. third-party marketplace widgets, custom widgets like CrusherWidgets) require a manual Studio Pro extraction workflow before they can be used in `create page` MDL commands. + +## Goal + +Any pluggable widget whose `.mpk` file is present in `project/widgets/` can be used in MDL commands without pre-built templates. The derivation happens transparently at runtime when the widget is first referenced. + +## Approach + +**Approach B — new `generate.go`, parallel to `augment.go`.** + +All logic for building individual PropertyType/Property pairs from scratch already exists in `augment.go` (`createPropertyPair`, `createDefaultValueType`, `createDefaultWidgetValue`, `buildNestedObjectType`, `xmlTypeToBSONType`). `generate.go` only needs to build the outer `CustomWidgetType` + `WidgetObject` shells and call those existing functions. + +Rejected alternatives: +- **A (extend augment.go)**: mixed "patch" and "generate" semantics in one file → harder to maintain +- **C (pipeline refactor)**: highest risk, touches stable loader.go core for marginal architectural gain + +## Architecture + +### File changes + +``` +sdk/widgets/ +├── mpk/mpk.go — unchanged (ParseMPK / FindMPK already complete) +├── augment.go — unchanged (all helpers reused as-is) +├── loader.go — +25 lines: fallback path after embedded cache miss +└── generate.go — new, ~60 lines: outer shell construction +``` + +### Template resolution flow + +``` +GetTemplateBSON / GetTemplateFullBSON(widgetID, idGenerator, projectPath) + │ + └─ getOrGenerateTemplate(widgetID, projectPath) ← new internal helper + │ + ├─ 1. GetTemplate(widgetID) ← embedded cache (existing) + │ └─ found → augmentFromMPK() → return + │ + ├─ 2. generatedCache.Load(widgetID) ← session cache (new) + │ └─ found → return + │ + ├─ 3. mpk.FindMPK(projectPath, widgetID) + │ └─ not found → return nil, nil + │ + ├─ 4. mpk.ParseMPK(mpkPath) → WidgetDefinition + │ + ├─ 5. GenerateFromMPK(def) → WidgetTemplate ← new + │ + └─ 6. generatedCache.Store(widgetID, tmpl) → return +``` + +**Performance fallback (deferred):** If step 5 proves too slow for large widgets, write the generated template to `.mxcli/widgets/.json` and check that path between steps 2 and 3. This can be added without restructuring. + +## generate.go + +### API + +```go +// GenerateFromMPK builds a complete WidgetTemplate from an MPK WidgetDefinition. +// All $IDs are placeholder IDs; loader.go's collectIDs remaps them to real UUIDs +// before BSON serialization. +func GenerateFromMPK(def *mpk.WidgetDefinition) *WidgetTemplate +``` + +### Algorithm + +``` +typeID = placeholderID() +objTypeID = placeholderID() + +for each p in def.Properties: + bsonType = xmlTypeToBSONType(p.Type) + skip if bsonType == "" + pt, prop = createPropertyPair(p, bsonType) + append pt → propTypes + append prop → objProps + +type = map{ + "$ID": typeID, + "$Type": "CustomWidgets$CustomWidgetType", + "WidgetId": def.ID, + "ObjectType": map{ + "$ID": objTypeID, + "$Type": "CustomWidgets$WidgetObjectType", + "PropertyTypes": [2, ...propTypes], + }, +} + +object = map{ + "$ID": placeholderID(), + "$Type": "CustomWidgets$WidgetObject", + "TypePointer": typeID, + "Properties": [2, ...objProps], +} + +return &WidgetTemplate{ + WidgetID: def.ID, + Name: def.Name, + Version: def.Version, + Type: type, + Object: object, +} +``` + +### System properties + +System properties (Label, Visibility, Editability) are **not** added by `GenerateFromMPK`. Studio Pro injects them automatically when opening the project. This matches the current behaviour of `AugmentTemplate`. + +## loader.go changes + +`GetTemplate(widgetID string)` has no `projectPath` parameter; it returns `nil, nil` on cache miss. +`projectPath` is only available in `GetTemplateBSON` / `GetTemplateFullBSON`. + +**Strategy:** introduce a package-internal `getOrGenerateTemplate(widgetID, projectPath string)` called by both BSON functions instead of `GetTemplate` directly. + +```go +// getOrGenerateTemplate returns the template for widgetID, falling back to +// MPK-based generation when no embedded template exists. +func getOrGenerateTemplate(widgetID, projectPath string) (*WidgetTemplate, error) { + // 1. embedded templates (existing path) + if tmpl, err := GetTemplate(widgetID); err != nil || tmpl != nil { + return tmpl, err + } + + // 2. session cache of previously generated templates + if cached, ok := generatedCache.Load(widgetID); ok { + return cached.(*WidgetTemplate), nil + } + + // 3. derive from MPK in project/widgets/ + if projectPath == "" { + return nil, nil // no project path, can't locate MPK + } + mpkPath := mpk.FindMPK(projectPath, widgetID) + if mpkPath == "" { + return nil, nil // not found — callers treat nil as "widget unknown" + } + def, err := mpk.ParseMPK(mpkPath) + if err != nil { + return nil, fmt.Errorf("widget %q: parse MPK: %w", widgetID, err) + } + tmpl := GenerateFromMPK(def) + generatedCache.Store(widgetID, tmpl) + return tmpl, nil +} +``` + +`GetTemplateBSON` and `GetTemplateFullBSON` replace their `GetTemplate(widgetID)` call with `getOrGenerateTemplate(widgetID, projectPath)`. No other changes to those functions. + +`generatedCache` is a package-level `sync.Map` (key: widgetID string, value: `*WidgetTemplate`). The cached value is the pre-ID-remapping template, identical in lifecycle to embedded templates. + +## PropertyDef → BSON mapping + +Already fully implemented in `augment.go:xmlTypeToBSONType()`. Covers all 17 known XML property types: + +`attribute` `expression` `textTemplate` `widgets` `enumeration` `boolean` `integer` `datasource` `action` `selection` `association` `object` `string` `decimal` `icon` `image` `file` + +Unknown types are silently skipped (property omitted from generated template). + +## Tests + +| Test | File | What it checks | +|------|------|----------------| +| `TestGenerateFromMPK_BasicTypes` | `generate_test.go` | string/boolean/integer/expression/attribute → PropertyTypes and Properties counts match, TypePointers cross-reference correctly | +| `TestGenerateFromMPK_NestedObject` | `generate_test.go` | object-type property with children → nested ObjectType built correctly | +| `TestGenerateFromMPK_UnknownTypeSkipped` | `generate_test.go` | unknown XML type → no panic, property count reduced by 1 | +| `TestGenerateFromMPK_PlaceholderIDsRemapped` | `generate_test.go` | after `GetTemplateFullBSON`, no `aa000000`-prefix IDs remain | +| `TestGetTemplate_FallsBackToMPK` | `loader_test.go` | CavitySelector widget ID + CrusherWidgets.mpk fixture → valid template returned without embedded template | + +**Acceptance criterion:** `create page` MDL command referencing CavitySelector in CrusherCopilot project → Studio Pro opens without CE0463 error. + +## Out of scope + +- ALTER PAGE operations on generated widget instances (follow-on work) +- Caching generated templates to disk (deferred until performance evidence) +- `cmd/crusher-templates` CLI (superseded by this runtime approach; can be removed separately) diff --git a/docs/superpowers/specs/2026-05-09-widget-realtime-registry-design.md b/docs/superpowers/specs/2026-05-09-widget-realtime-registry-design.md new file mode 100644 index 00000000..71e5a27d --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-widget-realtime-registry-design.md @@ -0,0 +1,150 @@ +# Widget Real-Time Registry Design + +**Date:** 2026-05-09 +**Branch:** feature/mpk-template-derivation +**Status:** Approved + +## Problem + +`mxcli widget init` pre-extracts `.def.json` files from `.mpk` widget packages into +`.mxcli/widgets/`. The command skips files that already exist on disk (no mtime or +version check), so upgrading a widget in Mendix Studio Pro silently leaves stale +definitions in place. Users must remember to delete `.def.json` files and re-run +`widget init` after every widget upgrade — a step that is easy to forget and produces +no visible error until BSON serialization produces a wrong page. + +## Goal + +Eliminate the staleness problem by making widget definition lookup real-time: when +`CREATE PAGE` references a widget that is not in the built-in registry, mxcli derives +the `WidgetDefinition` on-the-fly from the project's `widgets/*.mpk` files. No +pre-extraction step is required for normal operation. + +## Non-Goals + +- Removing support for hand-crafted `.def.json` overrides (kept as escape hatch). +- Removing the `widget init` / `widget extract` commands (repurposed, not deleted). +- Changing the BSON template derivation path (already handled on this branch). + +## Chosen Approach: Lazy MPK Derivation with Optional `.def.json` Override + +Lookup order (unchanged for built-ins, new fallback for unknowns): + +``` +MDL keyword + └─ Registry built-in (.def.json embedded at compile time) → hit: use it + └─ Registry user override (.mxcli/widgets/*.def.json) → hit: use it + └─ MPK pre-scan map (mdlName → widgetID) → hit: full-parse MPK + └─ derive WidgetDefinition, cache in-memory → use it + └─ not found → error +``` + +### Phase 1 — Session startup (project path known) + +Call `registry.SetProjectDir(projectDir)`, which triggers a **lightweight pre-scan**: + +- Glob `/widgets/*.mpk` +- For each MPK: open ZIP, read only `package.xml` (already implemented in + `mpk.getWidgetIDsFromMPK`) +- For each widgetID found: compute `deriveMDLName(widgetID)` → store in + `mpkNameMap[mdlName] = widgetID` +- Skip names already in the built-in or user-override registry (built-ins win) +- Cost: ~1–2 ms per widget; a project with 30 widgets adds ~50 ms at most + +This map is used by LSP completion to offer all available widget keywords without +requiring `widget init`. + +### Phase 2 — First use of a widget (CREATE PAGE execution) + +On `registry.Get(mdlName)` or `registry.GetByWidgetID(widgetID)` miss: + +1. Look up `widgetID` from `mpkNameMap` +2. Call `mpk.FindMPK(projectDir, widgetID)` → mpkPath +3. Call `mpk.ParseMPKForWidget(mpkPath, widgetID)` → `*mpk.WidgetDefinition` + (already cached in `mpk.defCache` after first parse) +4. Convert to `executor.WidgetDefinition` via `deriveFromMPK()` (logic moved from + `cmd_widget.go:generateDefJSON`) +5. Register in `byMDLName` and `byWidgetID` — subsequent lookups are O(1) + +### Phase 3 — Hand-crafted override (optional, for complex widgets) + +Users who need custom `Modes`, non-standard `Operation` types, or MDL name overrides +write a `.def.json` by hand (or use `widget extract` as a starting point). These +files in `.mxcli/widgets/` are loaded at startup before the pre-scan, so they win +over MPK derivation. + +## Dependency Analysis + +No circular dependency is introduced: + +``` +sdk/widgets/mpk → (stdlib only, zero mdl/ imports) +mdl/executor → sdk/widgets/definitions (existing) + → sdk/widgets/mpk (new, safe) +cmd/mxcli → mdl/executor + sdk/widgets/mpk (unchanged) +``` + +## Code Changes + +### `mdl/executor/widget_registry.go` + +Add fields: +```go +projectDir string +mpkNameMap map[string]string // mdlName (upper) → widgetID +``` + +Add methods: +- `SetProjectDir(dir string) error` — stores dir, calls `preScanWidgets` +- `preScanWidgets(dir string) error` — builds `mpkNameMap` +- `deriveFromMPK(widgetID string) (*WidgetDefinition, error)` — converts + `mpk.WidgetDefinition` to `executor.WidgetDefinition`; this is the + `generateDefJSON` logic moved here from `cmd_widget.go` + +Modify: +- `Get(name string)` — add MPK fallback after registry miss +- `GetByWidgetID(id string)` — add MPK fallback after registry miss + +New import: +- `github.com/mendixlabs/mxcli/sdk/widgets/mpk` + +### `cmd/mxcli/cmd_widget.go` + +- Remove `generateDefJSON` — replaced by `registry.deriveFromMPK` +- `widget init`: remove skip-if-exists guard; change help text to describe it as a + debugging/customization tool, not a required setup step; add `--force` flag to + overwrite existing files +- `widget extract`: unchanged + +### Executor / REPL initialization + +Wherever `executor.NewWidgetRegistry()` is called and a project path is available, +follow up with `registry.SetProjectDir(projectDir)`. Concrete locations: + +- `mdl/executor/executor.go` (or its `Context` initializer) +- `cmd/mxcli/repl.go` (REPL session start) +- Any backend init that receives a project path + +### `cmd/mxcli/cmd_widget.go` — `widget init` new help text + +``` +Extract and dump widget definitions for inspection or customization. + +mxcli widget init is no longer required for CREATE PAGE to work — definitions +are derived automatically from widgets/*.mpk at runtime. Run this command only +when you need to inspect or hand-edit a widget's property mappings. + +Flags: + --force overwrite existing .def.json files +``` + +## Testing + +- Unit test: `TestRegistryMPKFallback` — create a temp dir with a minimal MPK, + call `SetProjectDir`, verify `Get(derivedName)` returns a non-nil definition +- Unit test: `TestPreScanSkipsBuiltins` — verify that a built-in widget name (e.g. + `GALLERY`) in `mpkNameMap` is ignored in favour of the built-in registry entry +- Unit test: `TestDeriveFromMPKCached` — call `Get` twice for the same widget, + verify MPK is parsed only once (inspect `mpk.defCache`) +- Integration: existing `mdl-examples/doctype-tests/` page tests that use third-party + widgets should pass without `widget init` having been run diff --git a/mdl/backend/mpr/widget_builder.go b/mdl/backend/mpr/widget_builder.go index 6d9b826e..b08851e3 100644 --- a/mdl/backend/mpr/widget_builder.go +++ b/mdl/backend/mpr/widget_builder.go @@ -666,12 +666,13 @@ func convertPropertyTypeIDs(src map[string]widgets.PropertyTypeIDEntry) map[stri result := make(map[string]pages.PropertyTypeIDEntry) for k, v := range src { entry := pages.PropertyTypeIDEntry{ - PropertyTypeID: v.PropertyTypeID, - ValueTypeID: v.ValueTypeID, - DefaultValue: v.DefaultValue, - ValueType: v.ValueType, - Required: v.Required, - ObjectTypeID: v.ObjectTypeID, + PropertyTypeID: v.PropertyTypeID, + ValueTypeID: v.ValueTypeID, + DefaultValue: v.DefaultValue, + ValueType: v.ValueType, + Required: v.Required, + DataSourceProperty: v.DataSourceProperty, + ObjectTypeID: v.ObjectTypeID, } if len(v.NestedPropertyIDs) > 0 { entry.NestedPropertyIDs = convertPropertyTypeIDs(v.NestedPropertyIDs) diff --git a/mdl/executor/cmd_pages_builder.go b/mdl/executor/cmd_pages_builder.go index 6a8920c0..051637af 100644 --- a/mdl/executor/cmd_pages_builder.go +++ b/mdl/executor/cmd_pages_builder.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "log" + "path/filepath" "strings" "github.com/mendixlabs/mxcli/mdl/ast" @@ -66,6 +67,10 @@ func (pb *pageBuilder) initPluggableEngine() { if loadErr := registry.LoadUserDefinitions(pb.backend.Path()); loadErr != nil { log.Printf("warning: loading user widget definitions: %v", loadErr) } + projectDir := filepath.Dir(pb.backend.Path()) + if scanErr := registry.SetProjectDir(projectDir); scanErr != nil { + log.Printf("warning: widget pre-scan: %v", scanErr) + } } pb.widgetRegistry = registry pb.pluggableEngine = NewPluggableWidgetEngine(pb.widgetBackend, pb) diff --git a/mdl/executor/cmd_pages_builder_v3_layout.go b/mdl/executor/cmd_pages_builder_v3_layout.go index 020bcc99..cab9afa9 100644 --- a/mdl/executor/cmd_pages_builder_v3_layout.go +++ b/mdl/executor/cmd_pages_builder_v3_layout.go @@ -64,7 +64,7 @@ func (pb *pageBuilder) buildLayoutGridColumnV3(w *ast.WidgetV3) (*pages.LayoutGr ID: model.ID(types.GenerateID()), TypeName: "Forms$LayoutGridColumn", }, - Weight: 1, + Weight: 0, // 0 → columnWeight() maps to -1 (auto-fill) in the serializer } // Handle DesktopWidth diff --git a/mdl/executor/widget_engine.go b/mdl/executor/widget_engine.go index 745ca781..6976c68c 100644 --- a/mdl/executor/widget_engine.go +++ b/mdl/executor/widget_engine.go @@ -4,6 +4,7 @@ package executor import ( "fmt" + "log" "sort" "strings" @@ -130,6 +131,23 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* } } + // 3.5 Warn about DataSource-type properties that were not configured. + // This gives AI assistants actionable feedback when they forget DataSource: + for _, m := range mappings { + if m.Source != "DataSource" { + continue + } + if w.GetDataSource() == nil { + // Collect linked attribute keys for the hint + linked := linkedPropertyKeysFor(m.PropertyKey, propertyTypeIDs) + hint := fmt.Sprintf("HINT [%s]: property '%s' is a DataSource type and was not configured.\n"+ + " Add inside this widget block: DataSource: Module.Entity [where [XPath]]\n"+ + " Linked attribute properties that draw from this datasource: %s", + w.Name, m.PropertyKey, linked) + log.Print(hint) + } + } + // 4. Auto datasource: map AST DataSource to first DataSource-type property. // This must run before child slots so that entityContext is available // for child widgets that depend on the parent's data source. @@ -254,6 +272,20 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* if !ok { continue // not a known widget property key } + + // Handle action-type properties (e.g., onChange: nanoflow Module.NF) + if actionAST, isAction := propVal.(*ast.ActionV3); isAction { + if entry.ValueType == "Action" { + act, err := e.pageBuilder.buildClientActionV3(actionAST) + if err != nil { + log.Printf("warning: widget %s property %s: %v", w.Name, propName, err) + } else { + builder.SetAction(propName, act) + } + } + continue + } + // Convert non-string values (bool, int, float) to string for property setting var strVal string switch v := propVal.(type) { @@ -286,6 +318,24 @@ func (e *PluggableWidgetEngine) Build(def *WidgetDefinition, w *ast.WidgetV3) (* if attrPath != "" { builder.SetAttribute(propName, attrPath) } + // Warn when a linked attribute appears to use the context entity instead of + // the datasource entity. Only fires when the attribute path has 2+ dots + // (fully qualified) and the entity portion matches the context entity. + if entry.DataSourceProperty != "" && strings.Count(strVal, ".") >= 2 { + parts := strings.SplitN(strVal, ".", 3) + attrEntity := parts[0] + "." + parts[1] + // Warn when the attribute entity doesn't match the datasource entity. + // After DataSource: processes gridData, entityContext is set to the + // datasource entity — linked attrs should use that same entity. + if e.pageBuilder.entityContext != "" && !strings.EqualFold(attrEntity, e.pageBuilder.entityContext) { + log.Printf("HINT [%s]: property '%s' is linked to datasource '%s'.\n"+ + " The attribute should reference the datasource entity '%s', not '%s'.\n"+ + " Correct: %s.%s (or similar) — configure DataSource: first so the entity context is set.", + w.Name, propName, entry.DataSourceProperty, + e.pageBuilder.entityContext, attrEntity, + e.pageBuilder.entityContext, parts[2]) + } + } default: // Known non-attribute types: always use primitive if entry.ValueType != "" && entry.ValueType != "Attribute" { @@ -542,6 +592,22 @@ func (e *PluggableWidgetEngine) applyChildSlots(builder backend.WidgetObjectBuil return nil } +// linkedPropertyKeysFor returns a comma-separated list of property keys whose +// DataSourceProperty field matches dsKey, drawn from the propertyTypeIDs map. +func linkedPropertyKeysFor(dsKey string, propertyTypeIDs map[string]pages.PropertyTypeIDEntry) string { + var keys []string + for k, e := range propertyTypeIDs { + if e.DataSourceProperty == dsKey { + keys = append(keys, k) + } + } + sort.Strings(keys) + if len(keys) == 0 { + return "(none)" + } + return strings.Join(keys, ", ") +} + // isBuiltinPropName returns true for property names that are handled by // dedicated MDL keywords (DataSource, Attribute, etc.) rather than by // the explicit property pass. diff --git a/mdl/executor/widget_registry.go b/mdl/executor/widget_registry.go index 701c0bd3..6453465a 100644 --- a/mdl/executor/widget_registry.go +++ b/mdl/executor/widget_registry.go @@ -12,6 +12,7 @@ import ( mdlerrors "github.com/mendixlabs/mxcli/mdl/errors" "github.com/mendixlabs/mxcli/sdk/widgets/definitions" + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" ) // WidgetRegistry holds loaded widget definitions keyed by uppercase MDL name. @@ -19,6 +20,8 @@ type WidgetRegistry struct { byMDLName map[string]*WidgetDefinition // keyed by uppercase MDLName byWidgetID map[string]*WidgetDefinition // keyed by widgetId knownOperations map[string]bool // operations accepted during validation + projectDir string // project root for MPK fallback + mpkNameMap map[string]string // uppercase MDLName → widgetID (pre-scan) } // defaultKnownOperations is the set of operation names supported by the widget engine. @@ -66,6 +69,7 @@ func NewWidgetRegistryWithOps(extraOps map[string]bool) (*WidgetRegistry, error) byMDLName: make(map[string]*WidgetDefinition), byWidgetID: make(map[string]*WidgetDefinition), knownOperations: ops, + mpkNameMap: make(map[string]string), } entries, err := definitions.EmbeddedFS.ReadDir(".") @@ -105,14 +109,49 @@ func NewWidgetRegistryWithOps(extraOps map[string]bool) (*WidgetRegistry, error) // Get returns a widget definition by MDL name (case-insensitive). func (r *WidgetRegistry) Get(mdlName string) (*WidgetDefinition, bool) { - def, ok := r.byMDLName[strings.ToUpper(mdlName)] - return def, ok + name := strings.ToUpper(mdlName) + if def, ok := r.byMDLName[name]; ok { + return def, ok + } + if r.projectDir == "" { + return nil, false + } + widgetID, ok := r.mpkNameMap[name] + if !ok { + return nil, false + } + def, err := r.deriveFromMPK(widgetID) + if err != nil { + log.Printf("warning: MPK fallback for %s: %v", name, err) + return nil, false + } + if def == nil { + return nil, false + } + r.byMDLName[strings.ToUpper(def.MDLName)] = def + r.byWidgetID[def.WidgetID] = def + return def, true } // GetByWidgetID returns a widget definition by its full widget ID. func (r *WidgetRegistry) GetByWidgetID(widgetID string) (*WidgetDefinition, bool) { - def, ok := r.byWidgetID[widgetID] - return def, ok + if def, ok := r.byWidgetID[widgetID]; ok { + return def, ok + } + if r.projectDir == "" { + return nil, false + } + def, err := r.deriveFromMPK(widgetID) + if err != nil { + log.Printf("warning: MPK fallback for widget ID %s: %v", widgetID, err) + return nil, false + } + if def == nil { + return nil, false + } + r.byMDLName[strings.ToUpper(def.MDLName)] = def + r.byWidgetID[def.WidgetID] = def + return def, true } // All returns all registered definitions. @@ -273,3 +312,124 @@ func (r *WidgetRegistry) validateMappings(mappings []PropertyMapping, source, mo } return nil } + +// SetProjectDir enables real-time MPK fallback for this registry. +func (r *WidgetRegistry) SetProjectDir(projectDir string) error { + r.projectDir = projectDir + r.mpkNameMap = make(map[string]string) + return r.preScanWidgets(projectDir) +} + +func (r *WidgetRegistry) preScanWidgets(projectDir string) error { + widgetsDir := filepath.Join(projectDir, "widgets") + matches, err := filepath.Glob(filepath.Join(widgetsDir, "*.mpk")) + if err != nil { + return fmt.Errorf("scan widgets dir: %w", err) + } + for _, mpkPath := range matches { + defs, err := mpk.ParseAll(mpkPath) + if err != nil { + log.Printf("warning: widget pre-scan skipping %s: %v", filepath.Base(mpkPath), err) + continue + } + for _, d := range defs { + name := strings.ToUpper(lastIDSegment(d.ID)) + if _, exists := r.byMDLName[name]; exists { + continue // builtin or user-override wins + } + r.mpkNameMap[name] = d.ID + } + } + return nil +} + +func (r *WidgetRegistry) deriveFromMPK(widgetID string) (*WidgetDefinition, error) { + mpkPath, err := mpk.FindMPK(r.projectDir, widgetID) + if err != nil { + return nil, fmt.Errorf("find mpk for %s: %w", widgetID, err) + } + if mpkPath == "" { + return nil, nil + } + mpkDef, err := mpk.ParseMPKForWidget(mpkPath, widgetID) + if err != nil { + return nil, fmt.Errorf("parse mpk for %s: %w", widgetID, err) + } + if mpkDef == nil { + return nil, nil + } + return buildDefinitionFromMPK(mpkDef), nil +} + +// lastIDSegment returns the last dot-separated segment of a widget ID, lowercased. +func lastIDSegment(widgetID string) string { + parts := strings.Split(widgetID, ".") + return strings.ToLower(parts[len(parts)-1]) +} + +func buildDefinitionFromMPK(mpkDef *mpk.WidgetDefinition) *WidgetDefinition { + mdlName := lastIDSegment(mpkDef.ID) + widgetKind := "custom" + if mpkDef.IsPluggable { + widgetKind = "pluggable" + } + def := &WidgetDefinition{ + WidgetID: mpkDef.ID, + MDLName: mdlName, + WidgetKind: widgetKind, + TemplateFile: mdlName + ".json", + DefaultEditable: "Always", + } + + var assocMappings []PropertyMapping + for _, p := range mpkDef.Properties { + switch p.Type { + case "widgets": + container := strings.ToUpper(p.Key) + if p.Key == "content" { + container = "TEMPLATE" + } + def.ChildSlots = append(def.ChildSlots, ChildSlotMapping{ + PropertyKey: p.Key, + MDLContainer: strings.ToLower(container), + Operation: "widgets", + }) + case "datasource": + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "DataSource", + Operation: "datasource", + }) + case "attribute": + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "Attribute", + Operation: "attribute", + }) + case "association": + assocMappings = append(assocMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "Association", + Operation: "association", + }) + case "selection": + def.PropertyMappings = append(def.PropertyMappings, PropertyMapping{ + PropertyKey: p.Key, + Source: "Selection", + Operation: "selection", + Default: p.DefaultValue, + }) + case "boolean", "integer", "decimal", "string", "enumeration": + m := PropertyMapping{ + PropertyKey: p.Key, + Operation: "primitive", + } + if p.DefaultValue != "" { + m.Value = p.DefaultValue + } + def.PropertyMappings = append(def.PropertyMappings, m) + } + } + def.PropertyMappings = append(def.PropertyMappings, assocMappings...) + return def +} diff --git a/mdl/executor/widget_registry_mpk_test.go b/mdl/executor/widget_registry_mpk_test.go new file mode 100644 index 00000000..3b373ffe --- /dev/null +++ b/mdl/executor/widget_registry_mpk_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +// writeMiniMPK creates a minimal .mpk ZIP in widgetsDir with the given widget ID. +func writeMiniMPK(t *testing.T, widgetsDir, widgetID string) { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + // last segment of ID becomes the XML file name + parts := strings.Split(widgetID, ".") + xmlName := parts[len(parts)-1] + ".xml" + + pkg, _ := w.Create("package.xml") + fmt.Fprintf(pkg, + ``, + xmlName, + ) + + wxml, _ := w.Create(xmlName) + fmt.Fprintf(wxml, + `Test`, + widgetID, + ) + + w.Close() + + mpkPath := filepath.Join(widgetsDir, parts[len(parts)-1]+".mpk") + if err := os.WriteFile(mpkPath, buf.Bytes(), 0644); err != nil { + t.Fatalf("write MPK: %v", err) + } +} + +func TestRegistryMPKFallbackGet(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + writeMiniMPK(t, widgetsDir, "com.test.widget.Testwidget") + + reg, err := NewWidgetRegistry() + if err != nil { + t.Fatalf("NewWidgetRegistry: %v", err) + } + if err := reg.SetProjectDir(dir); err != nil { + t.Fatalf("SetProjectDir: %v", err) + } + + def, ok := reg.Get("TESTWIDGET") + if !ok { + t.Fatal("expected TESTWIDGET via MPK fallback, got not-found") + } + if def.WidgetID != "com.test.widget.Testwidget" { + t.Errorf("WidgetID = %q, want com.test.widget.Testwidget", def.WidgetID) + } + if def.WidgetKind != "pluggable" { + t.Errorf("WidgetKind = %q, want pluggable", def.WidgetKind) + } +} + +func TestRegistryMPKFallbackGetByWidgetID(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + writeMiniMPK(t, widgetsDir, "com.test.widget.Testwidget") + + reg, _ := NewWidgetRegistry() + reg.SetProjectDir(dir) + + def, ok := reg.GetByWidgetID("com.test.widget.Testwidget") + if !ok { + t.Fatal("expected GetByWidgetID fallback to find widget") + } + if def.MDLName != "testwidget" { + t.Errorf("MDLName = %q, want testwidget", def.MDLName) + } +} + +func TestRegistryMPKFallbackCached(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + writeMiniMPK(t, widgetsDir, "com.test.widget.Testwidget") + + reg, _ := NewWidgetRegistry() + reg.SetProjectDir(dir) + + def1, ok1 := reg.Get("TESTWIDGET") + if !ok1 { + t.Fatal("first lookup failed") + } + def2, ok2 := reg.Get("TESTWIDGET") + if !ok2 { + t.Fatal("second lookup failed") + } + if def1 != def2 { + t.Error("expected same pointer on second lookup (in-registry cache)") + } +} + +func TestRegistryMPKFallbackSkipsBuiltins(t *testing.T) { + mpk.ClearCache() + dir := t.TempDir() + widgetsDir := filepath.Join(dir, "widgets") + os.MkdirAll(widgetsDir, 0755) + // Write a fake MPK that would derive "GALLERY" — should be ignored + writeMiniMPK(t, widgetsDir, "com.mendix.widget.web.gallery.Gallery") + + reg, _ := NewWidgetRegistry() + builtinDef, builtinOK := reg.Get("GALLERY") + + reg.SetProjectDir(dir) + + afterDef, afterOK := reg.Get("GALLERY") + if builtinOK { + // Gallery is a builtin: SetProjectDir must not replace it + if afterDef != builtinDef { + t.Error("SetProjectDir must not override a built-in definition") + } + } else { + // Gallery not a builtin in this build: MPK fallback is fine + if !afterOK { + t.Error("expected GALLERY to be found after SetProjectDir") + } + } +} diff --git a/mdl/grammar/domains/MDLPage.g4 b/mdl/grammar/domains/MDLPage.g4 index 9426fd0a..7616f1bf 100644 --- a/mdl/grammar/domains/MDLPage.g4 +++ b/mdl/grammar/domains/MDLPage.g4 @@ -282,7 +282,9 @@ widgetPropertyV3 | EDITABLE COLON xpathConstraint // Editable: [Status != 'Closed'] | EDITABLE COLON propertyValueV3 // Editable: Never | Always | TOOLTIP COLON propertyValueV3 // Tooltip: 'text' + | IDENTIFIER COLON actionExprV3 // Named action: onChange: nanoflow Module.NF | IDENTIFIER COLON propertyValueV3 // Generic: any other property + | keyword COLON actionExprV3 // Named action (keyword key): onCommit: nanoflow ... | keyword COLON propertyValueV3 // Generic: keyword as property name (for pluggable widgets) ; diff --git a/mdl/visitor/visitor_page_v3.go b/mdl/visitor/visitor_page_v3.go index d5e3704d..64e58851 100644 --- a/mdl/visitor/visitor_page_v3.go +++ b/mdl/visitor/visitor_page_v3.go @@ -597,17 +597,24 @@ func parseWidgetPropertyV3(ctx parser.IWidgetPropertyV3Context, widget *ast.Widg return } - // Generic property: Identifier: value + // Named action property: onChange: nanoflow Module.NF if id := propCtx.IDENTIFIER(); id != nil { + if actCtx := propCtx.ActionExprV3(); actCtx != nil { + widget.Properties[id.GetText()] = buildActionV3(actCtx) + return + } if valCtx := propCtx.PropertyValueV3(); valCtx != nil { widget.Properties[id.GetText()] = buildPropertyValueV3(valCtx) } return } - // Generic property with keyword name: keyword: value (for pluggable widget property keys - // that happen to be MDL keywords, e.g., type, datasource, content) + // Generic property with keyword name: keyword: value (or named action) if kw := propCtx.Keyword(); kw != nil { + if actCtx := propCtx.ActionExprV3(); actCtx != nil { + widget.Properties[kw.GetText()] = buildActionV3(actCtx) + return + } if valCtx := propCtx.PropertyValueV3(); valCtx != nil { widget.Properties[kw.GetText()] = buildPropertyValueV3(valCtx) } diff --git a/sdk/mpr/reader_widgets.go b/sdk/mpr/reader_widgets.go index 4b06051c..e2bdf527 100644 --- a/sdk/mpr/reader_widgets.go +++ b/sdk/mpr/reader_widgets.go @@ -64,6 +64,89 @@ func (r *Reader) FindCustomWidgetType(widgetID string) (*RawCustomWidgetType, er return nil, nil // Not found } +// ListAllCustomWidgetTypes scans every page and snippet in the project and returns +// one RawCustomWidgetType per unique widget ID. This is used by extract-templates to +// discover all pluggable widgets without a hardcoded list. +func (r *Reader) ListAllCustomWidgetTypes() ([]*RawCustomWidgetType, error) { + units, err := r.listUnitsByType("Forms$Page") + if err != nil { + return nil, err + } + if snippets, err := r.listUnitsByType("Forms$Snippet"); err == nil { + units = append(units, snippets...) + } + + seen := make(map[string]bool) + var results []*RawCustomWidgetType + + for _, u := range units { + contents, err := r.resolveContents(u.ID, u.Contents) + if err != nil { + continue + } + if !strings.Contains(string(contents), "CustomWidgets$CustomWidget\"") { + continue + } + var doc bson.D + if err := bson.Unmarshal(contents, &doc); err != nil { + continue + } + for _, wid := range collectCustomWidgetIDs(doc) { + if seen[wid] { + continue + } + rawType, rawObject := extractWidgetTypeAndObject(contents, wid) + if rawType == nil { + continue + } + seen[wid] = true + results = append(results, &RawCustomWidgetType{ + WidgetID: wid, + RawType: rawType, + RawObject: rawObject, + UnitID: u.ID, + }) + } + } + return results, nil +} + +// collectCustomWidgetIDs walks a BSON document and collects all unique widgetId values +// found inside CustomWidgets$CustomWidgetType elements. +func collectCustomWidgetIDs(doc bson.D) []string { + var ids []string + var walk func(v any) + walk = func(v any) { + switch val := v.(type) { + case bson.D: + var isType bool + var widgetID string + for _, e := range val { + if e.Key == "$Type" && e.Value == "CustomWidgets$CustomWidgetType" { + isType = true + } + if e.Key == "WidgetId" { + if s, ok := e.Value.(string); ok { + widgetID = s + } + } + } + if isType && widgetID != "" { + ids = append(ids, widgetID) + } + for _, e := range val { + walk(e.Value) + } + case bson.A: + for _, item := range val { + walk(item) + } + } + } + walk(doc) + return ids +} + // FindAllCustomWidgetTypes searches for ALL CustomWidgets with the given // widgetID and returns their full Type/Object definitions as raw BSON. // This allows identification of different configurations of the same widget type. diff --git a/sdk/pages/pages_widgets_advanced.go b/sdk/pages/pages_widgets_advanced.go index 19acef63..02c2af69 100644 --- a/sdk/pages/pages_widgets_advanced.go +++ b/sdk/pages/pages_widgets_advanced.go @@ -171,11 +171,12 @@ type CustomWidget struct { // PropertyTypeIDEntry holds the IDs for a property type from a cloned widget. type PropertyTypeIDEntry struct { - PropertyTypeID string - ValueTypeID string - DefaultValue string // Default value from the template's ValueType - ValueType string // Type of value (Boolean, Integer, String, DataSource, etc.) - Required bool // Whether this property is required + PropertyTypeID string + ValueTypeID string + DefaultValue string // Default value from the template's ValueType + ValueType string // Type of value (Boolean, Integer, String, DataSource, etc.) + Required bool // Whether this property is required + DataSourceProperty string // Non-empty when this attribute is linked to another DataSource property // For object list properties (IsList=true with ObjectType), these hold nested IDs ObjectTypeID string // ID of the nested ObjectType (for object lists like columns) NestedPropertyIDs map[string]PropertyTypeIDEntry // Property IDs within the nested ObjectType diff --git a/sdk/widgets/augment.go b/sdk/widgets/augment.go index 2542a563..b007d62d 100644 --- a/sdk/widgets/augment.go +++ b/sdk/widgets/augment.go @@ -564,12 +564,18 @@ func createPropertyPair(p mpk.PropertyDef, bsonType string) (map[string]any, map // createDefaultValueType creates a default ValueType structure for a given BSON type. func createDefaultValueType(vtID string, bsonType string, p mpk.PropertyDef) map[string]any { + // Build AllowedTypes: version marker 1 followed by allowed Mendix type names. + allowedTypes := []any{float64(1)} + for _, t := range p.AllowedTypes { + allowedTypes = append(allowedTypes, t) + } + vt := map[string]any{ "$ID": vtID, "$Type": "CustomWidgets$WidgetValueType", "ActionVariables": []any{float64(2)}, "AllowNonPersistableEntities": false, - "AllowedTypes": []any{float64(1)}, + "AllowedTypes": allowedTypes, "AssociationTypes": []any{float64(1)}, "DataSourceProperty": "", "DefaultType": "None", diff --git a/sdk/widgets/augment_test.go b/sdk/widgets/augment_test.go index 05d93548..3aab03da 100644 --- a/sdk/widgets/augment_test.go +++ b/sdk/widgets/augment_test.go @@ -4,6 +4,8 @@ package widgets import ( "fmt" + "os" + "path/filepath" "testing" "github.com/mendixlabs/mxcli/sdk/widgets/mpk" @@ -662,6 +664,40 @@ func TestAugmentTemplate_NoPlaceholderLeakAfterBSONConversion(t *testing.T) { } } +func TestGetTemplateFullBSON_FallsBackToMPK(t *testing.T) { + crusherMprPath := filepath.Join("testdata", "crushertestproject", "testproject.mpr") + if _, err := os.Stat(crusherMprPath); err != nil { + t.Skip("crusher test fixture not available") + } + + ResetGeneratedCache() + + widgetID := "com.mendix.widget.custom.CavitySelector.CavitySelector" + + callCount := 0 + idGen := func() string { + callCount++ + return fmt.Sprintf("%032x", callCount) + } + + bsonType, bsonObj, propTypeIDs, _, err := GetTemplateFullBSON(widgetID, idGen, crusherMprPath) + if err != nil { + t.Fatalf("GetTemplateFullBSON: %v", err) + } + if bsonType == nil { + t.Fatal("expected non-nil bsonType") + } + if bsonObj == nil { + t.Fatal("expected non-nil bsonObj") + } + if len(propTypeIDs) == 0 { + t.Error("expected non-empty propTypeIDs") + } + if callCount == 0 { + t.Error("idGen was never called — IDs were not remapped") + } +} + // bsonTypeToXmlType is a test helper to reverse the mapping. func bsonTypeToXmlType(bsonType string) string { switch bsonType { diff --git a/sdk/widgets/generate.go b/sdk/widgets/generate.go new file mode 100644 index 00000000..93f2d8af --- /dev/null +++ b/sdk/widgets/generate.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package widgets + +import "github.com/mendixlabs/mxcli/sdk/widgets/mpk" + +// GenerateFromMPK builds a complete WidgetTemplate from a parsed MPK WidgetDefinition. +// All $IDs are placeholder IDs (aa000000... prefix). loader.go's collectIDs remaps them +// to real UUIDs before BSON serialisation — matching the lifecycle of embedded templates. +// System properties (Label, Visibility, Editability) are not added; Studio Pro injects them. +func GenerateFromMPK(def *mpk.WidgetDefinition) *WidgetTemplate { + typeID := placeholderID() + objTypeID := placeholderID() + + propTypes := []any{float64(2)} // Mendix array version marker + objProps := []any{float64(2)} + + for _, p := range def.Properties { + bsonType := xmlTypeToBSONType(p.Type) + if bsonType == "" { + continue // unknown XML type — skip silently + } + pt, prop := createPropertyPair(p, bsonType) + if pt != nil { + propTypes = append(propTypes, pt) + } + if prop != nil { + objProps = append(objProps, prop) + } + } + + platform := def.SupportedPlatform + if platform == "" { + platform = "Web" + } + + typeMap := map[string]any{ + "$ID": typeID, + "$Type": "CustomWidgets$CustomWidgetType", + "HelpUrl": def.HelpURL, + "OfflineCapable": def.OfflineCapable, + "StudioCategory": def.StudioCategory, + "StudioProCategory": def.StudioProCategory, + "SupportedPlatform": platform, + "WidgetDescription": def.Description, + "WidgetId": def.ID, + "WidgetName": def.Name, + "WidgetNeedsEntityContext": def.NeedsEntityContext, + "WidgetPluginWidget": def.IsPluggable, + "ObjectType": map[string]any{ + "$ID": objTypeID, + "$Type": "CustomWidgets$WidgetObjectType", + "PropertyTypes": propTypes, + }, + } + + objectMap := map[string]any{ + "$ID": placeholderID(), + "$Type": "CustomWidgets$WidgetObject", + "TypePointer": objTypeID, + "Properties": objProps, + } + + return &WidgetTemplate{ + WidgetID: def.ID, + Name: def.Name, + Version: def.Version, + Generated: true, + Type: typeMap, + Object: objectMap, + } +} diff --git a/sdk/widgets/generate_test.go b/sdk/widgets/generate_test.go new file mode 100644 index 00000000..3c823d93 --- /dev/null +++ b/sdk/widgets/generate_test.go @@ -0,0 +1,268 @@ +// SPDX-License-Identifier: Apache-2.0 + +package widgets + +import ( + "strings" + "testing" + + "github.com/mendixlabs/mxcli/sdk/widgets/mpk" +) + +func TestGenerateFromMPK_BasicTypes(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Version: "1.0.0", + Properties: []mpk.PropertyDef{ + {Key: "label", Type: "string", Caption: "Label"}, + {Key: "enabled", Type: "boolean", Caption: "Enabled", DefaultValue: "true"}, + {Key: "count", Type: "integer", Caption: "Count", DefaultValue: "0"}, + {Key: "value", Type: "expression", Caption: "Value"}, + {Key: "attr", Type: "attribute", Caption: "Attribute"}, + }, + } + + tmpl := GenerateFromMPK(def) + + if tmpl == nil { + t.Fatal("GenerateFromMPK returned nil") + } + if tmpl.WidgetID != def.ID { + t.Errorf("WidgetID = %q, want %q", tmpl.WidgetID, def.ID) + } + if tmpl.Name != def.Name { + t.Errorf("Name = %q, want %q", tmpl.Name, def.Name) + } + if tmpl.Version != def.Version { + t.Errorf("Version = %q, want %q", tmpl.Version, def.Version) + } + if tmpl.Type == nil { + t.Fatal("Type is nil") + } + if tmpl.Object == nil { + t.Fatal("Object is nil") + } + + if got := tmpl.Type["$Type"]; got != "CustomWidgets$CustomWidgetType" { + t.Errorf("Type.$Type = %v, want CustomWidgets$CustomWidgetType", got) + } + + objType, ok := tmpl.Type["ObjectType"].(map[string]any) + if !ok { + t.Fatal("Type.ObjectType missing or wrong type") + } + propTypes, ok := objType["PropertyTypes"].([]any) + if !ok { + t.Fatal("ObjectType.PropertyTypes missing or wrong type") + } + nonMarkerPropTypes := 0 + for _, pt := range propTypes { + if _, isFloat := pt.(float64); !isFloat { + nonMarkerPropTypes++ + } + } + if nonMarkerPropTypes != 5 { + t.Errorf("PropertyTypes count = %d, want 5", nonMarkerPropTypes) + } + + objProps, ok := tmpl.Object["Properties"].([]any) + if !ok { + t.Fatal("Object.Properties missing or wrong type") + } + nonMarkerProps := 0 + for _, p := range objProps { + if _, isFloat := p.(float64); !isFloat { + nonMarkerProps++ + } + } + if nonMarkerProps != 5 { + t.Errorf("Properties count = %d, want 5", nonMarkerProps) + } +} + +func TestGenerateFromMPK_TypePointerCrossReference(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + {Key: "mode", Type: "enumeration", Caption: "Mode", DefaultValue: "fast"}, + }, + } + + tmpl := GenerateFromMPK(def) + + objType := tmpl.Type["ObjectType"].(map[string]any) + objTypeID := objType["$ID"].(string) + propTypes := objType["PropertyTypes"].([]any) + var ptID string + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + if ptMap["PropertyKey"] == "mode" { + ptID = ptMap["$ID"].(string) + break + } + } + if ptID == "" { + t.Fatal("PropertyType for 'mode' not found") + } + + objProps := tmpl.Object["Properties"].([]any) + var propTypePointer string + for _, p := range objProps { + pMap, ok := p.(map[string]any) + if !ok { + continue + } + propTypePointer = pMap["TypePointer"].(string) + break + } + if propTypePointer != ptID { + t.Errorf("Property.TypePointer = %q, want PropertyType.$ID %q", propTypePointer, ptID) + } + + // Also verify the top-level objectMap TypePointer → WidgetObjectType.$ID + gotObjTP := tmpl.Object["TypePointer"].(string) + if gotObjTP != objTypeID { + t.Errorf("Object.TypePointer = %q, want ObjectType.$ID %q", gotObjTP, objTypeID) + } +} + +func TestGenerateFromMPK_NestedObject(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + { + Key: "columns", + Type: "object", + Caption: "Columns", + IsList: true, + Children: []mpk.PropertyDef{ + {Key: "header", Type: "string", Caption: "Header"}, + {Key: "attr", Type: "attribute", Caption: "Attribute"}, + }, + }, + }, + } + + tmpl := GenerateFromMPK(def) + + objType := tmpl.Type["ObjectType"].(map[string]any) + propTypes := objType["PropertyTypes"].([]any) + var columnsPT map[string]any + for _, pt := range propTypes { + ptMap, ok := pt.(map[string]any) + if !ok { + continue + } + if ptMap["PropertyKey"] == "columns" { + columnsPT = ptMap + break + } + } + if columnsPT == nil { + t.Fatal("columns PropertyType not found") + } + + vt, ok := columnsPT["ValueType"].(map[string]any) + if !ok { + t.Fatal("ValueType missing on columns property") + } + nestedObjType, ok := vt["ObjectType"].(map[string]any) + if !ok { + t.Fatal("ObjectType missing on columns ValueType — nested object not built") + } + nestedPTs, ok := nestedObjType["PropertyTypes"].([]any) + if !ok { + t.Fatal("nested PropertyTypes missing") + } + nestedCount := 0 + for _, npt := range nestedPTs { + if _, isFloat := npt.(float64); !isFloat { + nestedCount++ + } + } + if nestedCount != 2 { + t.Errorf("nested PropertyTypes count = %d, want 2", nestedCount) + } +} + +func TestGenerateFromMPK_UnknownTypeSkipped(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + {Key: "good", Type: "string", Caption: "Good"}, + {Key: "bad", Type: "unknownXmlType", Caption: "Bad"}, + }, + } + + tmpl := GenerateFromMPK(def) + + objType := tmpl.Type["ObjectType"].(map[string]any) + propTypes := objType["PropertyTypes"].([]any) + count := 0 + for _, pt := range propTypes { + if _, isFloat := pt.(float64); !isFloat { + count++ + } + } + if count != 1 { + t.Errorf("PropertyTypes count = %d, want 1 (unknown type skipped)", count) + } +} + +func TestGenerateFromMPK_PlaceholderIDsRemapped(t *testing.T) { + ResetPlaceholderCounter() + + def := &mpk.WidgetDefinition{ + ID: "com.example.Widget", + Name: "Test Widget", + Properties: []mpk.PropertyDef{ + {Key: "label", Type: "string", Caption: "Label"}, + }, + } + + tmpl := GenerateFromMPK(def) + + callCount := 0 + idGen := func() string { + callCount++ + return strings.Repeat("f", 32) + } + + templateCacheLock.Lock() + templateCache["com.example.Widget"] = tmpl + templateCacheLock.Unlock() + defer func() { + templateCacheLock.Lock() + delete(templateCache, "com.example.Widget") + templateCacheLock.Unlock() + }() + + bsonType, bsonObj, _, _, err := GetTemplateFullBSON("com.example.Widget", idGen, "") + if err != nil { + t.Fatalf("GetTemplateFullBSON: %v", err) + } + if containsPlaceholderID(bsonType) { + t.Error("placeholder IDs leaked in bsonType") + } + if bsonObj != nil && containsPlaceholderID(bsonObj) { + t.Error("placeholder IDs leaked in bsonObj") + } + if callCount == 0 { + t.Error("idGen was never called — IDs were not remapped") + } +} diff --git a/sdk/widgets/loader.go b/sdk/widgets/loader.go index 0fd876f3..be7a020a 100644 --- a/sdk/widgets/loader.go +++ b/sdk/widgets/loader.go @@ -106,6 +106,7 @@ type WidgetTemplate struct { Name string `json:"name"` Version string `json:"version"` ExtractedFrom string `json:"extractedFrom"` + Generated bool `json:"-"` // true if derived from MPK, not from embedded template Type map[string]any `json:"type"` Object map[string]any `json:"object"` // WidgetObject with all property values } @@ -116,6 +117,10 @@ var ( templateCacheLock sync.RWMutex ) +// generatedCache stores MPK-derived templates for the session lifetime. +// Key: widgetID string. Value: *WidgetTemplate (placeholder IDs, not yet remapped). +var generatedCache sync.Map + // widgetTemplateIndex maps widget IDs to template filenames. // Built lazily by scanning embedded template JSON files. var ( @@ -201,12 +206,50 @@ func GetTemplate(widgetID string) (*WidgetTemplate, error) { return &tmpl, nil } +// getOrGenerateTemplate returns a WidgetTemplate for widgetID. It checks the embedded +// template cache first, then falls back to deriving a template from the project's .mpk +// widget file. Returns nil, nil when the widget is unknown and no MPK is available. +func getOrGenerateTemplate(widgetID, projectPath string) (*WidgetTemplate, error) { + // 1. Embedded templates (existing path) + if tmpl, err := GetTemplate(widgetID); err != nil || tmpl != nil { + return tmpl, err + } + + // 2. Session cache of previously generated templates + if cached, ok := generatedCache.Load(widgetID); ok { + return cached.(*WidgetTemplate), nil + } + + // 3. Derive from MPK in project/widgets/ + if projectPath == "" { + return nil, nil + } + projectDir := filepath.Dir(projectPath) + mpkPath, err := mpk.FindMPK(projectDir, widgetID) + if err != nil { + return nil, fmt.Errorf("widget %q: scan MPK directory: %w", widgetID, err) + } + if mpkPath == "" { + return nil, nil // no MPK found — caller treats nil as "widget unknown" + } + def, err := mpk.ParseMPKForWidget(mpkPath, widgetID) + if err != nil { + return nil, fmt.Errorf("widget %q: parse MPK: %w", widgetID, err) + } + if def == nil { + return nil, nil + } + tmpl := GenerateFromMPK(def) + generatedCache.Store(widgetID, tmpl) + return tmpl, nil +} + // GetTemplateBSON loads a widget template and converts its type definition to BSON. // The returned bson.D can be used directly in widget creation. // IDs in the template are regenerated with new UUIDs while preserving internal references. // If projectPath is non-empty, the template is augmented from the project's .mpk widget file. func GetTemplateBSON(widgetID string, idGenerator func() string, projectPath string) (bson.D, map[string]PropertyTypeIDEntry, error) { - tmpl, err := GetTemplate(widgetID) + tmpl, err := getOrGenerateTemplate(widgetID, projectPath) if err != nil { return nil, nil, err } @@ -214,8 +257,10 @@ func GetTemplateBSON(widgetID string, idGenerator func() string, projectPath str return nil, nil, nil } - // Deep-clone and augment from .mpk - tmpl = augmentFromMPK(tmpl, widgetID, projectPath) + // Deep-clone and augment from .mpk (skip for generated templates — already complete) + if !tmpl.Generated { + tmpl = augmentFromMPK(tmpl, widgetID, projectPath) + } // Phase 1: Collect all $ID values and create old->new ID mappings idMapping := make(map[string]string) @@ -238,7 +283,7 @@ func GetTemplateBSON(widgetID string, idGenerator func() string, projectPath str // If projectPath is non-empty, the template is augmented from the project's .mpk widget file. // Returns: (clonedType, clonedObject, propertyTypeIDs, objectTypeID, error) func GetTemplateFullBSON(widgetID string, idGenerator func() string, projectPath string) (bson.D, bson.D, map[string]PropertyTypeIDEntry, string, error) { - tmpl, err := GetTemplate(widgetID) + tmpl, err := getOrGenerateTemplate(widgetID, projectPath) if err != nil { return nil, nil, nil, "", err } @@ -246,8 +291,10 @@ func GetTemplateFullBSON(widgetID string, idGenerator func() string, projectPath return nil, nil, nil, "", nil } - // Deep-clone and augment from .mpk - tmpl = augmentFromMPK(tmpl, widgetID, projectPath) + // Deep-clone and augment from .mpk (skip for generated templates — already complete) + if !tmpl.Generated { + tmpl = augmentFromMPK(tmpl, widgetID, projectPath) + } // Phase 1: Collect all $ID values from Type and create old->new ID mappings idMapping := make(map[string]string) @@ -292,6 +339,7 @@ func jsonToBSONWithMappingAndObjectType(data map[string]any, idMapping map[strin var defaultValue string var valueType string var required bool + var dataSourceProp string var nestedObjectTypeID string var nestedPropertyIDs map[string]PropertyTypeIDEntry @@ -335,7 +383,7 @@ func jsonToBSONWithMappingAndObjectType(data map[string]any, idMapping map[strin } else if key == "ValueType" && isPropertyType { // For PropertyTypes, extract ValueType info including nested ObjectType, DefaultValue, Type, Required nestedPropertyIDs = make(map[string]PropertyTypeIDEntry) - elem.Value = jsonValueToBSONWithNestedObjectType(val, idMapping, &valueTypeID, &nestedObjectTypeID, nestedPropertyIDs, &defaultValue, &valueType, &required) + elem.Value = jsonValueToBSONWithNestedObjectType(val, idMapping, &valueTypeID, &nestedObjectTypeID, nestedPropertyIDs, &defaultValue, &valueType, &required, &dataSourceProp) } else { elem.Value = jsonValueToBSONWithMappingAndObjectType(val, idMapping, propertyTypeIDs, &valueTypeID, key == "ValueType", objectTypeID) } @@ -346,11 +394,12 @@ func jsonToBSONWithMappingAndObjectType(data map[string]any, idMapping map[strin // Record PropertyType IDs if isPropertyType && propertyKey != "" { entry := PropertyTypeIDEntry{ - PropertyTypeID: propertyTypeIDVal, - ValueTypeID: valueTypeID, - DefaultValue: defaultValue, - ValueType: valueType, - Required: required, + PropertyTypeID: propertyTypeIDVal, + ValueTypeID: valueTypeID, + DefaultValue: defaultValue, + ValueType: valueType, + Required: required, + DataSourceProperty: dataSourceProp, } if nestedObjectTypeID != "" { entry.ObjectTypeID = nestedObjectTypeID @@ -363,7 +412,7 @@ func jsonToBSONWithMappingAndObjectType(data map[string]any, idMapping map[strin } // jsonValueToBSONWithNestedObjectType extracts ValueType info including nested ObjectType, DefaultValue, and Type. -func jsonValueToBSONWithNestedObjectType(val any, idMapping map[string]string, valueTypeID *string, nestedObjectTypeID *string, nestedPropertyIDs map[string]PropertyTypeIDEntry, defaultValue *string, valueType *string, required *bool) any { +func jsonValueToBSONWithNestedObjectType(val any, idMapping map[string]string, valueTypeID *string, nestedObjectTypeID *string, nestedPropertyIDs map[string]PropertyTypeIDEntry, defaultValue *string, valueType *string, required *bool, dataSourceProperty ...*string) any { switch v := val.(type) { case map[string]any: result := make(bson.D, 0, len(v)) @@ -403,6 +452,12 @@ func jsonValueToBSONWithNestedObjectType(val any, idMapping map[string]string, v *required = r } elem.Value = jsonValueToBSONSimple(fieldVal, idMapping) + } else if key == "DataSourceProperty" && len(dataSourceProperty) > 0 && dataSourceProperty[0] != nil { + // Extract datasource linkage: non-empty when this attribute draws from a sibling datasource property + if dsp, ok := fieldVal.(string); ok { + *dataSourceProperty[0] = dsp + } + elem.Value = jsonValueToBSONSimple(fieldVal, idMapping) } else { elem.Value = jsonValueToBSONSimple(fieldVal, idMapping) } @@ -702,11 +757,12 @@ func jsonValueToBSONObjectWithMapping(val any, idMapping map[string]string) any // PropertyTypeIDEntry holds the IDs for a property type. type PropertyTypeIDEntry struct { - PropertyTypeID string - ValueTypeID string - DefaultValue string // Default value from the template's ValueType - ValueType string // Type of value (Boolean, Integer, String, DataSource, etc.) - Required bool // Whether this property is required + PropertyTypeID string + ValueTypeID string + DefaultValue string // Default value from the template's ValueType + ValueType string // Type of value (Boolean, Integer, String, DataSource, etc.) + Required bool // Whether this property is required + DataSourceProperty string // Non-empty when this attribute is linked to another DataSource property // For object list properties (IsList=true with ObjectType), these hold nested IDs ObjectTypeID string // ID of the nested ObjectType (for object lists) NestedPropertyIDs map[string]PropertyTypeIDEntry // Property IDs within the nested ObjectType @@ -907,10 +963,13 @@ func augmentFromMPK(tmpl *WidgetTemplate, widgetID string, projectPath string) * return tmpl } - def, err := mpk.ParseMPK(mpkPath) + def, err := mpk.ParseMPKForWidget(mpkPath, widgetID) if err != nil { return tmpl } + if def == nil { + return tmpl + } // Deep-clone so we don't mutate the cached template clone, err := deepCloneTemplate(tmpl) @@ -926,6 +985,14 @@ func augmentFromMPK(tmpl *WidgetTemplate, widgetID string, projectPath string) * return clone } +// ResetGeneratedCache clears the MPK-derived template cache (for testing). +func ResetGeneratedCache() { + generatedCache.Range(func(k, _ any) bool { + generatedCache.Delete(k) + return true + }) +} + // ListAvailableTemplates returns a list of available widget template IDs. func ListAvailableTemplates() []string { index := getWidgetTemplateIndex() diff --git a/sdk/widgets/mpk/mpk.go b/sdk/widgets/mpk/mpk.go index 944a6bd6..766905ad 100644 --- a/sdk/widgets/mpk/mpk.go +++ b/sdk/widgets/mpk/mpk.go @@ -27,17 +27,25 @@ type PropertyDef struct { IsList bool IsSystem bool // true for elements DataSource string // dataSource attribute reference + AllowedTypes []string // for attribute properties: Mendix type names ("String", "Decimal", etc.) Children []PropertyDef // nested properties for object-type properties } // WidgetDefinition holds the parsed definition of a pluggable widget from an .mpk file. type WidgetDefinition struct { - ID string // e.g. "com.mendix.widget.web.combobox.Combobox" - Name string // e.g. "Combo box" - Version string // from package.xml clientModule version - IsPluggable bool // true if pluginWidget="true" (React), false for legacy Dojo - Properties []PropertyDef // regular elements - SystemProps []PropertyDef // elements + ID string // e.g. "com.mendix.widget.web.combobox.Combobox" + Name string // e.g. "Combo box" + Description string // widget description from element + Version string // from package.xml clientModule version + IsPluggable bool // true if pluginWidget="true" (React), false for legacy Dojo + OfflineCapable bool // true if offlineCapable="true" + NeedsEntityContext bool // true if needsEntityContext="true" + SupportedPlatform string // "Web", "Native", "All" (empty = Web) + HelpURL string // helpUrl attribute + StudioCategory string // studioCategory attribute + StudioProCategory string // studioProCategory attribute + Properties []PropertyDef // regular elements + SystemProps []PropertyDef // elements } // --- XML structures for parsing --- @@ -61,10 +69,17 @@ type xmlWidgetFile struct { // xmlWidget represents root element in widget XML. type xmlWidget struct { - ID string `xml:"id,attr"` - PluginWidget string `xml:"pluginWidget,attr"` - Name string `xml:"name"` - PropertyGroups []xmlPropGroup `xml:"properties>propertyGroup"` + ID string `xml:"id,attr"` + PluginWidget string `xml:"pluginWidget,attr"` + OfflineCapable string `xml:"offlineCapable,attr"` + NeedsEntityContext string `xml:"needsEntityContext,attr"` + SupportedPlatform string `xml:"supportedPlatform,attr"` + HelpURL string `xml:"helpUrl,attr"` + StudioCategory string `xml:"studioCategory,attr"` + StudioProCategory string `xml:"studioProCategory,attr"` + Name string `xml:"name"` + Description string `xml:"description"` + PropertyGroups []xmlPropGroup `xml:"properties>propertyGroup"` } // xmlPropGroup represents element. @@ -75,16 +90,22 @@ type xmlPropGroup struct { SubGroups []xmlPropGroup `xml:"propertyGroup"` } +// xmlAttributeType represents element. +type xmlAttributeType struct { + Name string `xml:"name,attr"` +} + // xmlProperty represents element. type xmlProperty struct { - Key string `xml:"key,attr"` - Type string `xml:"type,attr"` - DefaultValue string `xml:"defaultValue,attr"` - Required string `xml:"required,attr"` - IsList string `xml:"isList,attr"` - DataSource string `xml:"dataSource,attr"` - Caption string `xml:"caption"` - Description string `xml:"description"` + Key string `xml:"key,attr"` + Type string `xml:"type,attr"` + DefaultValue string `xml:"defaultValue,attr"` + Required string `xml:"required,attr"` + IsList string `xml:"isList,attr"` + DataSource string `xml:"dataSource,attr"` + Caption string `xml:"caption"` + Description string `xml:"description"` + AttributeTypes []xmlAttributeType `xml:"attributeTypes>attributeType"` // Nested properties for object type NestedProps []xmlPropGroup `xml:"properties>propertyGroup"` } @@ -190,17 +211,7 @@ func ParseMPK(mpkPath string) (*WidgetDefinition, error) { return nil, fmt.Errorf("failed to parse %s: %w", widgetFilePath, err) } - def := &WidgetDefinition{ - ID: widget.ID, - Name: widget.Name, - Version: version, - IsPluggable: widget.PluginWidget == "true", - } - - // Walk property groups to collect properties - for _, pg := range widget.PropertyGroups { - walkPropertyGroup(pg, "", def) - } + def := buildDefinition(&widget, version) // Cache defCacheLock.Lock() @@ -225,6 +236,12 @@ func walkPropertyGroup(pg xmlPropGroup, parentCategory string, def *WidgetDefini // Collect regular properties for _, p := range pg.Properties { + var allowedTypes []string + for _, at := range p.AttributeTypes { + if at.Name != "" { + allowedTypes = append(allowedTypes, at.Name) + } + } prop := PropertyDef{ Key: p.Key, Type: p.Type, @@ -235,6 +252,7 @@ func walkPropertyGroup(pg xmlPropGroup, parentCategory string, def *WidgetDefini DefaultValue: p.DefaultValue, IsList: p.IsList == "true", DataSource: p.DataSource, + AllowedTypes: allowedTypes, } // Parse nested properties for object-type properties @@ -266,6 +284,12 @@ func walkPropertyGroup(pg xmlPropGroup, parentCategory string, def *WidgetDefini // within an object-type property and appends them to the parent PropertyDef. func collectNestedProperties(pg xmlPropGroup, parent *PropertyDef) { for _, p := range pg.Properties { + var allowedTypes []string + for _, at := range p.AttributeTypes { + if at.Name != "" { + allowedTypes = append(allowedTypes, at.Name) + } + } child := PropertyDef{ Key: p.Key, Type: p.Type, @@ -275,6 +299,7 @@ func collectNestedProperties(pg xmlPropGroup, parent *PropertyDef) { DefaultValue: p.DefaultValue, IsList: p.IsList == "true", DataSource: p.DataSource, + AllowedTypes: allowedTypes, } parent.Children = append(parent.Children, child) } @@ -307,15 +332,18 @@ func FindMPK(projectDir string, widgetID string) (string, error) { return "", fmt.Errorf("failed to scan widgets directory: %w", err) } - // Build mapping by parsing each .mpk's package.xml and widget XML + // Build mapping by parsing each .mpk's package.xml and widget XML. + // Multi-widget MPKs list multiple widget IDs; map each one to this file. dirMap := make(map[string]string) for _, mpkPath := range matches { - wid, err := getWidgetIDFromMPK(mpkPath) + wids, err := getWidgetIDsFromMPK(mpkPath) if err != nil { continue // Skip unparseable files } - if wid != "" { - dirMap[wid] = mpkPath + for _, wid := range wids { + if wid != "" { + dirMap[wid] = mpkPath + } } } @@ -327,82 +355,195 @@ func FindMPK(projectDir string, widgetID string) (string, error) { return dirMap[widgetID], nil } -// getWidgetIDFromMPK extracts the widget ID from an .mpk file without fully parsing it. -func getWidgetIDFromMPK(mpkPath string) (string, error) { +// getWidgetIDsFromMPK returns ALL widget IDs declared in an .mpk package.xml. +// Multi-widget MPKs (e.g. CrusherWidgets.mpk) list multiple entries. +func getWidgetIDsFromMPK(mpkPath string) ([]string, error) { r, err := zip.OpenReader(mpkPath) if err != nil { - return "", err + return nil, err } defer r.Close() - // Find package.xml to get widget file path - var widgetFilePath string + var widgetFilePaths []string var totalExtracted uint64 for _, f := range r.File { if f.Name == "package.xml" { if f.UncompressedSize64 > maxFileSize { - return "", fmt.Errorf("package.xml exceeds max file size (%d > %d)", f.UncompressedSize64, maxFileSize) + return nil, fmt.Errorf("package.xml exceeds max file size (%d > %d)", f.UncompressedSize64, maxFileSize) } rc, err := f.Open() if err != nil { - return "", err + return nil, err } data, err := io.ReadAll(rc) rc.Close() if err != nil { - return "", err + return nil, err } totalExtracted += uint64(len(data)) if totalExtracted > maxTotalSize { - return "", fmt.Errorf("total extracted size exceeds limit (%d > %d)", totalExtracted, maxTotalSize) + return nil, fmt.Errorf("total extracted size exceeds limit") } var pkg xmlPackage if err := xml.Unmarshal(data, &pkg); err != nil { - return "", err + return nil, err } - if len(pkg.ClientModule.WidgetFiles) > 0 { - widgetFilePath = pkg.ClientModule.WidgetFiles[0].Path + for _, wf := range pkg.ClientModule.WidgetFiles { + widgetFilePaths = append(widgetFilePaths, wf.Path) } break } } - if widgetFilePath == "" { - return "", nil + var ids []string + for _, wfPath := range widgetFilePaths { + for _, f := range r.File { + if f.Name != wfPath { + continue + } + if f.UncompressedSize64 > maxFileSize { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + totalExtracted += uint64(len(data)) + if totalExtracted > maxTotalSize { + return ids, fmt.Errorf("total extracted size exceeds limit") + } + var widget struct { + ID string `xml:"id,attr"` + } + if err := xml.Unmarshal(data, &widget); err != nil { + continue + } + if widget.ID != "" { + ids = append(ids, widget.ID) + } + } + } + return ids, nil +} + +// buildDefinition constructs a WidgetDefinition from a parsed xmlWidget and version string. +func buildDefinition(widget *xmlWidget, version string) *WidgetDefinition { + platform := widget.SupportedPlatform + if platform == "" { + platform = "Web" + } + def := &WidgetDefinition{ + ID: widget.ID, + Name: widget.Name, + Description: widget.Description, + Version: version, + IsPluggable: widget.PluginWidget == "true", + OfflineCapable: widget.OfflineCapable == "true", + NeedsEntityContext: widget.NeedsEntityContext == "true", + SupportedPlatform: platform, + HelpURL: widget.HelpURL, + StudioCategory: widget.StudioCategory, + StudioProCategory: widget.StudioProCategory, + } + for _, pg := range widget.PropertyGroups { + walkPropertyGroup(pg, "", def) + } + return def +} + +// ParseMPKForWidget parses the widget XML for a specific widgetID from an .mpk file. +// Unlike ParseMPK (which reads only the first widget), this scans all widget files +// declared in package.xml to find the one whose ID matches widgetID. +// Needed for multi-widget .mpk packages (e.g. CrusherWidgets.mpk). +// Returns nil, nil when widgetID is not found in the MPK. +func ParseMPKForWidget(mpkPath string, widgetID string) (*WidgetDefinition, error) { + cacheKey := mpkPath + "\x00" + widgetID + defCacheLock.RLock() + if def, ok := defCache[cacheKey]; ok { + defCacheLock.RUnlock() + return def, nil } + defCacheLock.RUnlock() + + r, err := zip.OpenReader(mpkPath) + if err != nil { + return nil, fmt.Errorf("failed to open mpk: %w", err) + } + defer r.Close() - // Read widget XML to get the id attribute + var pkg xmlPackage + var version string + var totalExtracted uint64 for _, f := range r.File { - if f.Name == widgetFilePath { + if f.Name == "package.xml" { if f.UncompressedSize64 > maxFileSize { - return "", fmt.Errorf("%s exceeds max file size (%d > %d)", widgetFilePath, f.UncompressedSize64, maxFileSize) + return nil, fmt.Errorf("package.xml exceeds max size") } rc, err := f.Open() if err != nil { - return "", err + return nil, fmt.Errorf("open package.xml: %w", err) } data, err := io.ReadAll(rc) rc.Close() if err != nil { - return "", err + return nil, fmt.Errorf("read package.xml: %w", err) } totalExtracted += uint64(len(data)) if totalExtracted > maxTotalSize { - return "", fmt.Errorf("total extracted size exceeds limit (%d > %d)", totalExtracted, maxTotalSize) + return nil, fmt.Errorf("total extracted size exceeds limit (%d > %d)", totalExtracted, maxTotalSize) + } + if err := xml.Unmarshal(data, &pkg); err != nil { + return nil, fmt.Errorf("parse package.xml: %w", err) } + version = pkg.ClientModule.Version + break + } + } - // Quick XML parse to just get the id attribute - var widget struct { - ID string `xml:"id,attr"` + for _, wf := range pkg.ClientModule.WidgetFiles { + for _, f := range r.File { + if f.Name != wf.Path { + continue } + if f.UncompressedSize64 > maxFileSize { + continue + } + rc, err := f.Open() + if err != nil { + continue + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + continue + } + totalExtracted += uint64(len(data)) + if totalExtracted > maxTotalSize { + return nil, fmt.Errorf("total extracted size exceeds limit (%d > %d)", totalExtracted, maxTotalSize) + } + + var widget xmlWidget if err := xml.Unmarshal(data, &widget); err != nil { - return "", err + continue } - return widget.ID, nil + if widget.ID != widgetID { + continue + } + + def := buildDefinition(&widget, version) + defCacheLock.Lock() + defCache[cacheKey] = def + defCacheLock.Unlock() + return def, nil } } - return "", nil + return nil, nil } // PropertyKeys returns a set of regular (non-system) property keys from the definition. @@ -445,6 +586,26 @@ func ClearCache() { dirCacheLock.Unlock() } +// ParseAll parses every widget definition bundled in an MPK file and returns them all. +// For single-widget MPKs this returns a one-element slice. For multi-widget MPKs (where +// package.xml lists multiple entries) every widget is returned. Errors for +// individual widgets are skipped; only fatal archive errors are returned. +func ParseAll(mpkPath string) ([]*WidgetDefinition, error) { + ids, err := getWidgetIDsFromMPK(mpkPath) + if err != nil { + return nil, err + } + var result []*WidgetDefinition + for _, id := range ids { + def, err := ParseMPKForWidget(mpkPath, id) + if err != nil || def == nil { + continue + } + result = append(result, def) + } + return result, nil +} + // xmlPropertyTypeMapping maps lowercased XML property type names to their canonical camelCase forms. var xmlPropertyTypeMapping = map[string]string{ "attribute": "attribute", diff --git a/sdk/widgets/mpk/mpk_test.go b/sdk/widgets/mpk/mpk_test.go index c2fbf222..b4668e78 100644 --- a/sdk/widgets/mpk/mpk_test.go +++ b/sdk/widgets/mpk/mpk_test.go @@ -184,6 +184,54 @@ func TestNormalizeType(t *testing.T) { } } +func TestFindMPK_MultiWidget(t *testing.T) { + ClearCache() + projectDir := filepath.Join("..", "testdata", "crushertestproject") + if _, err := os.Stat(projectDir); err != nil { + t.Skip("crusher test fixture not available") + } + + widgets := []string{ + "com.mendix.widget.custom.CavitySelector.CavitySelector", + "com.mendix.widget.custom.CrusherSlider.CrusherSlider", + "com.mendix.widget.custom.PredictionBadge.PredictionBadge", + "com.mendix.widget.custom.CrusherSimCanvas.CrusherSimCanvas", + "com.mendix.widget.custom.HeatmapViz.HeatmapViz", + } + for _, wid := range widgets { + found, err := FindMPK(projectDir, wid) + if err != nil { + t.Fatalf("FindMPK(%q): %v", wid, err) + } + if found == "" { + t.Errorf("FindMPK(%q): expected MPK path, got empty string", wid) + } + } +} + +func TestParseMPKForWidget_MultiWidget(t *testing.T) { + ClearCache() + mpkPath := filepath.Join("..", "testdata", "crushertestproject", "widgets", "CrusherWidgets.mpk") + if _, err := os.Stat(mpkPath); err != nil { + t.Skip("crusher test fixture not available") + } + + widgetID := "com.mendix.widget.custom.CavitySelector.CavitySelector" + def, err := ParseMPKForWidget(mpkPath, widgetID) + if err != nil { + t.Fatalf("ParseMPKForWidget: %v", err) + } + if def == nil { + t.Fatal("ParseMPKForWidget: got nil definition") + } + if def.ID != widgetID { + t.Errorf("ID = %q, want %q", def.ID, widgetID) + } + if len(def.Properties) == 0 { + t.Error("expected at least one property") + } +} + func fileExists(path string) bool { _, err := os.Stat(path) return err == nil diff --git a/sdk/widgets/testdata/crushertestproject/testproject.mpr b/sdk/widgets/testdata/crushertestproject/testproject.mpr new file mode 100644 index 00000000..e69de29b diff --git a/sdk/widgets/testdata/crushertestproject/widgets/CrusherWidgets.mpk b/sdk/widgets/testdata/crushertestproject/widgets/CrusherWidgets.mpk new file mode 100644 index 00000000..aeb53d44 Binary files /dev/null and b/sdk/widgets/testdata/crushertestproject/widgets/CrusherWidgets.mpk differ