@emfts/uimodel-composer
v0.0.2-next.1
Published
UIModel-driven Vue 3 composer for EMF.ts models
Maintainers
Readme
@emfts/uimodel-composer
UIModel-getriebener Vue-3-Composer für EMF.ts-Modelle.
Statt UIs von Hand zu bauen, wird die Oberfläche als UIModel (Ecore/XMI) beschrieben und zur
Laufzeit interpretiert: Der UIModelComposer rendert aus einem geladenen UIModel und einem
Domänen-EObject reaktiv die passende Vue-Komponenten-Hierarchie.
Features
- View-Typen:
FormView,SectionView,TabView,TableView,SummaryView,MasterDetail - Widgets: Input, TextArea, Number, Checkbox, Date, Combobox, Select, ReferenceLink —
Rendering über die
@emfts/vue-registry(eigene Widgets pro EDataType registrierbar) - Styles: kaskadierende Style-Ketten (
BaseStyle,LayoutStyle,WidgetStyle,TableStyle) mitresolveStyleChain - Expressions: Sichtbarkeit und Validierung modellgetrieben via JS- oder OCL-Ausdrücken
(
registerOclEvaluatorfür einen OCL-Adapter, z. B.@emfts/ocl.langium) - Validierung:
ValidationExpression+ValidationMessageMappermit Severities - CSS-Styling (
http://uimodel/css/1.0): modelliertes, CSS-basiertes Styling (Design-Tokens, Themes, benannte Styles, Selektor-Regeln) — Teil dieses Pakets, siehe unten - Erweiterungs-Pakete (eigene EPackages, verändern das Core-UIModel nicht):
@emfts/uimodel-vega(Charts),@emfts/uimodel-maps(Karten, QGIS-orientiert),@emfts/uimodel-diagram(Diagramme) - Editor:
apps/editor— Baum + Live-Preview + reflektives Property-Panel für UI-Modelle und Styles, mit Undo/Redo und XMI-Speichern
Installation
npm install @emfts/uimodel-composer vue@emfts/core und @emfts/vue-registry kommen als Dependencies mit; vue ist
Peer-Dependency.
Die Renderer für Charts, Karten und Diagramme sind eigene Pakete und bringen ihre Bibliotheken als Peer-Dependencies mit — installiere nur, was du nutzt:
| Erweiterung | Paket | zusätzlich |
|---|---|---|
| Vega-Charts | @emfts/uimodel-vega | vega-embed |
| Karten (OpenLayers) | @emfts/uimodel-maps | ol |
| Diagramme (ELK) | @emfts/uimodel-diagram | elkjs |
Der Core selbst hängt an keiner dieser Bibliotheken.
Quick Start
import { createApp } from 'vue';
import { EmftsRendererPlugin, componentRegistry } from '@emfts/vue-registry';
import { registerEcorePackage, EPackageRegistry } from '@emfts/core';
import { UimodelPackage, UimodelFactory } from '@emfts/uimodel-composer';
// 1. Packages registrieren (Ecore-Basis, UIModel, eigene Domänen-Packages)
registerEcorePackage();
const uimodel = UimodelPackage.eINSTANCE;
uimodel.setEFactoryInstance(UimodelFactory.eINSTANCE);
EPackageRegistry.INSTANCE.set(uimodel.getNsURI()!, uimodel);
// 2. UIModel- und Domänen-XMI über ein ResourceSet laden, dann App mounten
const app = createApp(App, { uiModel, model });
app.use(EmftsRendererPlugin, { registry: componentRegistry });
app.mount('#app');<template>
<UIModelComposer :ui-model="uiModel" :model="model" />
</template>
<script setup lang="ts">
import { UIModelComposer, type UIModel } from '@emfts/uimodel-composer';
import type { EObject } from '@emfts/core';
defineProps<{ uiModel: UIModel; model: EObject }>();
</script>Wichtig bei den Erweiterungs-Paketen: @emfts/uimodel-vega,
@emfts/uimodel-maps und das CSS-Sub-Entry erst nach der Registrierung des
Core-UIModels dynamisch importieren — deren Package-Initialisierung liest
Component aus der EPackageRegistry. Die Renderer werden der ComposerRegistry
beigesteuert:
import { VegaViewComposer } from '@emfts/uimodel-vega';
const registry = createComposerRegistry({ ...defaults, VegaView: VegaViewComposer });Ein vollständiges, lauffähiges Setup (XMI-Loading, OCL-Adapter, eigene Widgets) zeigt die
Example-App unter apps/example.
Modell & Codegen
Die TypeScript-Klassen unter src/generated/ werden nicht von Hand gepflegt, sondern mit
@emfts/codegen aus den Ecore-Modellen unter model/ generiert:
npm run generate # Core-UIModel (model/uimodel.ecore)
npm run generate:css # CSS-Styling (model/uimodel-css.ecore)
# Erweiterungs-Pakete generieren aus ihrem eigenen Verzeichnis:
npm run generate --workspace packages/uimodel-vega
npm run generate --workspace packages/uimodel-mapsÄnderungen am Metamodell immer im .ecore vornehmen und anschließend neu generieren.
AllFeatures — generische Feature-Platzhalter
AllFeatures extends WidgetComponent ist ein Platzhalter in FormView.fields
(#2,
#4): der
FormViewComposer expandiert ihn beim Iterieren gegen die EClass des
Domänenobjekts und rendert die Treffer als normale Widgets — ohne eigenes
Markup; Überschriften rendern Konsumenten pro group (z. B. über das
gestempelte data-uim-group). Die geerbten Widget-Eigenschaften des
Platzhalters (label, readOnly, required, styles, bindings, …) sind
die Default-Konfiguration jedes Treffers:
- Auswahl:
with(explizite Features, definierte Reihenfolge) oder Grundmenge (alle Features inkl. geerbte), zugeschnitten durcheTypeundfilter(UND).filterist ein Meta-Ausdruck —selfist dasEStructuralFeature:self.derived,self.iD === true,self.eClass().getName() === 'EAttribute',self.name === 'description'(JS; Getter-Konventionderived→isDerived()übernimmt der Expression-Proxy). - Konflikte:
priority(höher gewinnt) → Spezifität (with> gefiltert) → Dokument-Reihenfolge. Jedes Feature landet in höchstens einem Block (Dedup pro UIModel); explizit gebundene Widgets gewinnen immer. - Template:
templatereferenziert einenWidgetComponent-Prototyp ohne gebundenesfeature(href in Vorlagen-Dateien möglich). Pro Treffer wird geklont,featuregebunden und das Label aus dem Feature-Namen abgeleitet. - Widget-Wahl als Fallliste (
cases: TemplateCase[*], #5): geordnete Fallunterscheidung —whenist ein Meta-Ausdruck (fail-closed), erster Treffer gewinnt,widgetreferenziert einen Prototyp ausUIModel.templates(oder per href eine Vorlagen-Datei).templatebleibt als Kurzform „ein Default-Fall ohne when“ (nach den cases geprüft). Trifft nichts und es gibt keinen Default-Fall, ist das ein Renderfehler — ein eingebautes Code-Mapping (defaultWidgetFor) existiert nicht mehr; das frühere Mapping steht als explizite Fallliste im mitgelieferten generischen Default-Modell und ist damit lesbar und überschreibbar. - Workspace-Overlays (
UIModelOverlay, #8): Wurzelkonzept ohne Struktur — nur Prototypen-Katalog +cases, die die Widget-Wahl der Expansion punktuell übersteuern (geprüft vor den lokalen cases;priorityreiht mehrere Overlays, explizit gebundene Widgets sind nie betroffen). Transport überExpansionContext.overlayCases(collectOverlayCases(overlays)), bequem als<UIModelComposer :overlays="[…]">. - API: purer Kern
expandFeatures(eClass, block, context)+collectExpansionContext(uiModel); Expansion imFormViewComposer(der frühereAllFeaturesComposerist entfernt, #7). - required: wird aus der Multiplizität abgeleitet (
lowerBound >= 1, explizitesrequiredam Prototyp/Block gewinnt); dabei wird eine Required-ValidationExpressiongeneriert, sofern der Prototyp keine eigenen Validations mitbringt (dokumentierte Konvention, #7).
Kanonisches Beispiel: model/templates/generic-default.uimodel.xmi
(ID → Attribute → Referenzen → Derived, klassenunabhängig) — im Editor über die
UIModel-Auswahl der Vorschau live erlebbar.
PropertyBinding — expression-fähige Widget-Parameter
WidgetComponent.bindings: PropertyBinding[*] bindet Widget-Parameter an
Expressions (Issue #3):
- Auflösung: Binding-Ergebnis > statischer Wert am Widget > Ableitung
(z. B. Label aus dem Feature-Namen). Ausgewertet reaktiv zur Renderzeit
in
useWidgetConfig/WidgetComposer. - Kontext:
self= Domänenobjekt (wievisibilityCondition), zusätzlichfeature(gebundenesEStructuralFeature, Meta-Ebene) undeClass. JS voll unterstützt (evaluateValue), OCL aufself-Ausdrücke beschränkt. - Koersion: boolesche Parameter →
Boolean(result), numerische →Number(result), sonstString(result). Sonderfallproperty="feature": Ergebnis muss einEStructuralFeaturesein,null⇒ Widget wird nicht gerendert. Fail-open:undefined⇒ statischer Wert gilt. - Templates:
UIModel.templatesist der Katalog für Widget-Prototypen;AllFeatures.templatereferenziert dorthin (oder per href in eine Vorlagen-Datei). Bindings am Prototyp werden pro Treffer mitgeklont. - Geerbte Bindings:
AllFeatures.bindings(vonWidgetComponentgeerbt) gilt für jedes expandierte Widget — auch ohne Template, das Typ-Mapping bleibt erhalten (widget-/template-eigene Bindings gewinnen bei gleichemproperty). Damit lassen sich Labels generisch aus EAnnotations ziehen:
<components xsi:type="uimodel:FormView" name="attributes" group="Attributes">
<fields xsi:type="uimodel:AllFeatures" name="attribute">
<filter language="JS" body="self.eClass().getName() === 'EAttribute' && !self.derived"/>
<bindings property="label">
<expression language="JS"
body="feature.getEAnnotation('http://uimodel/1.0')?.getDetails()?.get('label') ?? feature.name"/>
</bindings>
</fields>
</components>Die Expansion setzt außerdem required aus der Multiplizität
(lowerBound >= 1), sofern der Prototyp nichts vorgibt.
Strukturelle Platzhalter — GroupWidget, Conditional, ForEach
Drei weitere fields-Platzhalter
(#6), frei
komponierbar; aufgelöst vom puren Kern resolveStructure und gerendert
vom FieldsRenderer (kein Markup über das Nötigste hinaus):
GroupWidget(fields+layout): Container-Widget mituimodel-group uimodel-group--<layout>-Klassen (Styling beim Konsumenten/CSS-Modell). Als Prototyp imtemplates-Katalog ergibt es komplexe Templates — die ganze Gruppe wird pro Treffer geklont; Nachfahren ohne eigenesfeatureerben das expandierte Feature.Conditional(condition+then/else): gruppenfähiges if mit else-Zweig;self= Domänenobjekt, fail-open wievisibilityCondition.ForEach(items+body, optionalemptyText): Iterator über eine Instanz-Collection —bodywird pro Element gerendert,self/modelist dort das Element (Widget-featurereferenziert die Element-Klasse; Bindings, Validierung und CSS-condition-Regeln wirken pro Element).
<fields xsi:type="uimodel:ForEach" name="colleagues" emptyText="Keine Kollegen.">
<items language="JS" body="self.company?.employees"/>
<body xsi:type="uimodel:GroupWidget" name="row" layout="HORIZONTAL">
<fields xsi:type="uimodel:InputWidget" name="firstName"
feature="…//Person/firstName" readOnly="true"/>
<fields xsi:type="uimodel:InputWidget" name="jobTitle"
feature="…//Person/jobTitle" readOnly="true"/>
</body>
</fields>Widget-Extension-Pakete
Eigene Widget-Typen (Code-, Markdown-, RichText-Editoren …) gehören nicht
in den uimodel-Kern, sondern in ein eigenes EPackage des Hosts — analog zu
uimodel-vega.ecore/uimodel-maps.ecore
(#9):
- EPackage definieren: eigener
nsURI(z. B.http://gene/uimodel/widgets/1.0), Klassen erweiternhttp://uimodel/1.0#//WidgetComponentper href und ergänzen eigene Attribute (language,preview,toolbarItems, …). Codegen wie bei den anderen Extensions mit-d model/uimodel.ecore --import-mapping. - Registrieren: das Package nach dem Core-UIModel dynamisch
importieren und in die
EPackageRegistrylegen (_init()liestWidgetComponentaus der Registry). - Renderer beisteuern: über die
@emfts/vue-registry; die Komponente liest Konfiguration auscustom.resolvedStyle/custom.rawWidget.
Die Kern-Mechanik ist EClass-agnostisch: cloneComponent instanziiert über
die Factory des jeweiligen EPackage, TemplateCase-/Overlay-Matching,
PropertyBindings und das CSS-Klassen-Stamping arbeiten reflektiv. Extension-
Widgets brauchen dafür generierte Impls, die von den exportierten
Basisklassen (ComponentImpl, WidgetComponentImpl) erben —
DynamicEObjects reichen nicht, weil die Expansion Property-Accessors nutzt.
Verifiziert in src/allfeatures/widgetExtension.test.ts
(Expansion, TemplateCase, UIModelOverlay, Bindings auf Extension-Attributen).
Fallback: Liefert die Registry für ein Feature keine Komponente, rendert
der Composer ein FallbackWidget (Label + Plaintext-Editor auf dem gebundenen
Feature) und meldet das einmal pro Widget-Klasse per console.warn — statt
still leer zu bleiben; Daten gehen dabei nicht verloren.
Live-Reaktivität (Expression-Tick)
EObjects sind keine Vue-Reactive-Sources — Expression-Ergebnisse
(Bindings, Visibility, Validierung, Conditional/ForEach,
condition-StyleRules) hängen deshalb am globalen Expression-Tick
(#7):
- Der
UIModelComposerhängt automatisch einenEContentAdapteran das übergebene Domänenobjekt (useModelTick) — Widget-Edits und programmatischeeSet-Aufrufe wirken sofort. - Für Änderungen, die der Adapter nicht sieht (andere Resources,
Struktur-Änderungen am UIModel selbst), rufen Konsumenten
bumpExpressionTick(); eigene expression-abhängige computeds lesentrackExpressionTick().
CSS-Styling-Modell (http://uimodel/css/1.0)
model/uimodel-css.ecore modelliert Styling CSS-nah, aber
vollständig als EMF-Modell (eigene Resource, z. B. styles.xmi):
| Konzept | EClass | CSS-Analogon |
|---|---|---|
| StyleSheet | Wurzel: tokens, themes, styles, rules | Stylesheet |
| DesignToken | name/value → --uic-<name> auf :root | Custom Property |
| Theme + TokenOverride | Token-Overrides, Aktivierung via Klasse uicss-theme-<name> | Theme-Scope |
| CssStyle (extends BaseStyle!) | benannte Klasse .uic-<name>, aus Component.styles referenzierbar, extends-Kaskade | CSS-Klasse |
| CssDeclaration | property/value/important; token(<name>) → var(--uic-<name>) | Deklaration |
| CssState | HOVER/FOCUS/ACTIVE/DISABLED/READONLY/INVALID/REQUIRED | Pseudo-Klasse |
| StyleRule | Selektor: targetClass (± Subtypen), componentName, group, condition (JS/OCL, dynamisch), priority, media | CSS-Regel |
Die CSS-Engine (generateCss) übersetzt ein StyleSheet in echtes CSS;
useStyleSheetInjection hält es reaktiv (EContentAdapter) als <style>-Element aktuell.
Die Composer stempeln dafür Klassen und data-Attribute auf jede gerenderte Komponente:
.uim-component .uim-c-<EClass … inkl. Supertypen> → Typ-Selektoren
[data-uim-eclass|name|group] → exakte Selektoren
.uic-<name> → referenzierte CssStyles
.uim-s-required|-readonly|… → Widget-Zustände
.uicss-cond-<sheet>-<i> → erfüllte condition-RegelnVerwendung:
<UIModelComposer :ui-model="uiModel" :model="model" :style-sheets="[styleSheet]" />Das CSS-EPackage wird wie die Erweiterungs-Pakete nach dem Core-Package dynamisch importiert:
const { UimodelCssPackage, UimodelCssFactory } = await import('@emfts/uimodel-composer/css');Editor-App
apps/editor ist ein web-basierter Editor für UI-Modelle und Styles
(orientiert an gene): Outline-Baum über beide Resources, Live-Preview mit
Theme-Umschalter und CSS-Quelltext-Ansicht, reflektives Property-Panel
(Attribute, Enums, Referenzen mit Kandidaten-Auflösung) und ein
Deklarations-Editor für CssStyle/StyleRule/CssState mit
CSS-Property- und token(…)-Vorschlägen. Änderungen laufen als Commands
über @emfts/command (Undo/Redo, Strg+Z/Y), gespeichert wird als
XMI-Download (Strg+S).
npm install # im Repo-Root (npm workspaces)
npm run build # alle Pakete bauen
npm run dev:editor # Editor startenExample-App
npm install # im Repo-Root
npm run build
npm run dev:exampleDie Example-App (apps/example) demonstriert Formulare, Tabellen, Master-Detail, Vega-Charts und
OpenLayers-Karten auf Basis eigener Domänen-Modelle (example/model/).
Entwicklung
npm run build # vue-tsc --noEmit && vite build
npm run type-check # nur Typprüfung
npm test # vitest (watch)
npm run test:run # vitest einmalig