Skip to content

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 siehe ui-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-picker
  • vehicles:component:vehicle-picker
  • routing: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
});
DependencyFeature fehltuseFeatureComponent()Effekt
requiresBoot-Error-App startet nicht
optionalRequiresOKgibt nullFeature 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 Error
  • optionalRequires + kein Renderer-Support → Warning, Component ist null

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

PositionWoBeispiel
topUeber dem Formular/ListeWarnungen, Status-Banner
bottomUnter dem Formular/ListeAudit Trail, Comments, Tags
sidebarRechte Seite (Desktop only)Activity Feed, Related Items
toolbarIn der Button-LeisteExtra Actions (PDF, Export)

Target-Optionen

TargetEffekt
{ 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) => booleanZusaetzlich 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 Target
r.screenExtension({ target: { screen: "orders:screen:order-edit" } })
// Navigation
navigate("orders:screen:order-list")

Siehe Qualified Names fuer das vollstaendige QN-Pattern.