UI-Komponenten zwischen Features
Zusammenhang: Dieses Dokument beschreibt Cross-Feature UI-Patterns — Components, Actions, Screen Extensions. Fuer die Renderer-Architektur (Core vs. Renderer, Hooks, Primitives) siehe
ui-renderer.md. Fuer Screen-/Form-/Layout-Patterns sieheui-architecture.md.
Problem
Grosse Screens (z.B. Order-Edit) brauchen UI-Bausteine aus vielen Features: ClientPicker, VehiclePicker, Map, Preisrechner. Ohne klares Pattern kennt der Screen alles und wird unwartbar.
Loesung: r.uiComponent()
Jedes Feature registriert UI-Komponenten die andere Features einbetten koennen. Pro Plattform (mit Fallback):
defineFeature("clients", (r) => { r.uiComponent("clientPicker", { react: ClientPickerWeb, native: ClientPickerMobile, // optional — fallback auf react });});
defineFeature("vehicles", (r) => { r.uiComponent("vehiclePicker", { react: VehiclePickerComponent, // nur Web, kein native });});
defineFeature("routing", (r) => { r.uiComponent("routePreview", { react: RoutePreviewWeb, native: RoutePreviewMobile, });});Qualifiziert zu:
clients:component:client-pickervehicles:component:vehicle-pickerrouting:component:route-preview
Einbetten in Custom Screens: useFeatureComponent()
function OrderEditScreen() { const ClientPicker = useFeatureComponent("clients:component:client-picker"); const RoutePreview = useFeatureComponent("routing:component:route-preview");
return ( <> <Section title="Kunde"> <ClientPicker value={order.clientId} onChange={setClientId} /> </Section> <Section title="Route"> {RoutePreview && <RoutePreview from={order.pickup} to={order.delivery} />} </Section> </> );}Der Hook gibt die richtige Plattform-Implementierung zurueck — react auf Web, native auf Expo (mit Fallback auf react falls kein native registriert).
Einbetten in Config-driven Screens
Statt Standard-Select eine Custom Component:
r.screen({ id: "orderEdit", type: "entityEdit", layout: { sections: [{ fields: [ "customer", { field: "pickupAddressId", component: "address-autocomplete:component:address-picker", }, ], }], },});Der Renderer laed die Component via QN und rendert sie statt des Standard-Fields.
Kommunikation: Props rein, Callbacks raus
<RoutePreview from={order.pickupAddress} to={order.deliveryAddress} onCalculated={(result) => updateOrder({ distanceKm: result.distance, durationMin: result.duration, })}/>
<PriceCalculator distance={order.distanceKm} clientId={order.clientId} onCalculated={(prices) => updateOrder({ buyingPrice: prices.buying, sellingPrice: prices.selling, })}/>Jede Komponente managed eigene Rechte, Queries, Validierung. Der einbettende Screen kennt nur die Props.
Dependencies: requires vs. optionalRequires
defineFeature("orders", (r) => { r.requires("clients", "vehicles"); // Muss da sein → Boot-Error wenn fehlt r.optionalRequires("routing", "pricing"); // Kann da sein → kein Error wenn fehlt});| Dependency | Feature fehlt | useFeatureComponent() | Effekt |
|---|---|---|---|
requires | Boot-Error | - | App startet nicht |
optionalRequires | OK | gibt null | Feature rendert Fallback oder nichts |
const RoutePreview = useFeatureComponent("routing:component:route-preview");
{RoutePreview ? ( <RoutePreview from={...} to={...} onCalculated={...} />) : ( // Kein Routing-Feature → manuelle Eingabe als Fallback <NumberInput label="Distanz (km)" value={order.distanceKm} onChange={...} />)}Zusaetzlich prueft die Boot-Validation ob die Component fuer den aktiven Renderer verfuegbar ist:
requires+ kein Renderer-Support → Boot ErroroptionalRequires+ kein Renderer-Support → Warning, Component istnull
Rechte pro Komponente
Jede Komponente entscheidet selbst ueber Rechte:
function VehiclePickerComponent({ value, onChange }) { const { roles } = useCurrentUser(); const canEdit = roles.includes("Disponent") || roles.includes("Admin");
if (!canEdit) return <VehicleReadonly vehicleId={value} />; return <VehicleSearch value={value} onSelect={onChange} />;}Der einbettende Screen braucht keine Rechte-Logik fuer fremde Features.
Actions: Component schreibt Daten ins Formular
Problem: PriceListPicker (Feature A) will einen Preis ins Order-Formular (Feature B) uebertragen. In Custom Screens geht das ueber Callbacks. In config-driven Screens braucht das Framework ein Mapping.
Component definiert Actions
r.uiComponent("priceListPicker", { react: PriceListPickerWeb, native: PriceListPickerMobile, actions: { applyPrice: z.object({ buyingPrice: z.number(), sellingPrice: z.number(), currency: z.string(), }), },});Component loest Action aus
function PriceListPickerWeb({ context, emitAction }) { const { data: prices } = useCommand("pricing:query:price-list:for-client", { clientId: context.clientId, });
return ( <div> {prices.map((price) => ( <Button key={price.id} label={`${price.name}: ${price.selling}€`} onPress={() => emitAction("applyPrice", { buyingPrice: price.buying, sellingPrice: price.selling, currency: price.currency, })} /> ))} </div> );}Config-driven Screen mappt Action auf Formular-Felder
r.screen({ id: "orderEdit", type: "entityEdit", layout: { sections: [{ title: "orders:section.pricing", fields: [ "buyingPrice", "sellingPrice", { component: "pricing:component:price-list-picker", context: ["clientId", "distanceKm"], onAction: { applyPrice: { buyingPrice: "buyingPrice", // Action-Feld → Formular-Feld sellingPrice: "sellingPrice", currency: "currency", }, }, }, ], }], },});Custom Screen: Callbacks wie gehabt
In Custom Screens braucht man Actions nicht — da verdrahtet man Callbacks direkt:
<PriceListPicker context={{ clientId: order.clientId }} onApplyPrice={(prices) => updateOrder({ buyingPrice: prices.buying, sellingPrice: prices.selling, })}/>Screen Extensions: Features klinken sich in fremde Screens ein
Features koennen sich in Screens einklinken die ihnen nicht gehoeren — ohne dass der Screen-Besitzer etwas aendern muss.
Registrierung: r.screenExtension()
// Audit Feature → haengt sich an ALLE entityEdit Screens:defineFeature("audit", (r) => { r.screenExtension({ target: { type: "entityEdit" }, // Alle entityEdit Screens position: "bottom", component: { react: AuditTrailSection, native: AuditTrailSectionMobile, }, });});
// Tags → nur wo r.tags() aktiv:defineFeature("tags", (r) => { r.screenExtension({ target: { type: "entityEdit" }, position: "bottom", component: { react: TagPickerSection }, filter: (screen) => isTaggable(screen.entity), });});
// Comments → nur ein bestimmter Screen:defineFeature("comments", (r) => { r.screenExtension({ target: { screen: "orders:screen:order-detail" }, // QN position: "sidebar", component: { react: CommentsPanel }, });});
// Export-Button → in die Toolbar aller entityList Screens:defineFeature("export", (r) => { r.screenExtension({ target: { type: "entityList" }, position: "toolbar", component: { react: ExportButton }, });});Positionen
| Position | Wo | Beispiel |
|---|---|---|
top | Ueber dem Formular/Liste | Warnungen, Status-Banner |
bottom | Unter dem Formular/Liste | Audit Trail, Comments, Tags |
sidebar | Rechte Seite (Desktop only) | Activity Feed, Related Items |
toolbar | In der Button-Leiste | Extra Actions (PDF, Export) |
Target-Optionen
| Target | Effekt |
|---|---|
{ type: "entityEdit" } | Alle entityEdit Screens |
{ type: "entityList" } | Alle entityList Screens |
{ type: "custom" } | Alle custom Screens |
{ screen: "orders:screen:order-detail" } | Nur dieser eine Screen (via QN) |
+ filter: (screen) => boolean | Zusaetzlich filtern |
Effekt
- Audit Feature installieren → jeder Edit-Screen hat automatisch Audit Trail
- Tags Feature +
r.tags("order")→ Order-Edit hat automatisch Tag-Picker - Export Feature installieren → jede Liste hat Export-Button
- Kein Feature muss seinen Screen anpassen
Zusammenspiel mit QN
Alle Cross-Feature Referenzen nutzen QNs — validiert beim Boot:
// UI Component Referenz{ component: "clients:component:client-picker" }
// Field Renderer via QN{ field: "client", renderer: "clients:component:client-badge" }
// Screen Extension Targetr.screenExtension({ target: { screen: "orders:screen:order-edit" } })
// Navigationnavigate("orders:screen:order-list")Siehe Qualified Names fuer das vollstaendige QN-Pattern.