Zum Hauptinhalt springen
Sachbearbeiter-Cockpit

Datenquellen

Kein Widget der Library lädt selbst Daten. Stattdessen bekommt es eine Datenquelle hereingereicht: eine Funktion, die ein React-Query-Ergebnis liefert.

dataSource: () => UseQueryResult<JsonObject, Error>
const useAkte = () => useFile(id); // die Quelle ist selbst ein Hook

<Field dataSource={useAkte} jsonPath="title.de" /> // ✅ benannte Quelle
<List dataSource={useFiles} columns={columns} /> // ✅ Hook direkt
<Field dataSource={useFile(id)} jsonPath="title.de" /> // ❌ Klammern zu früh: Ergebnis statt Funktion
<Field dataSource={() => useFile(id)} jsonPath="" /> // ❌ Hook im Inline-Callback (siehe unten)

Weil in der Funktion Hooks stecken, gelten die Hook-Regeln: Das Widget ruft dataSource() bei jedem Render unbedingt auf — die Funktion darf also nicht bedingt einen Hook aufrufen und nicht zwischen zwei verschiedenen Hooks hin- und herwechseln.

Wann () => — und wann der Hook direkt?

Der Grund für den Wrapper ist eine einzige Tatsache: das Widget ruft dataSource() ohne Argumente auf. Alles, was der Hook braucht, muss die Funktion also selbst mitbringen — entweder aus dem Closure oder indem sie es sich per Hook holt.

Daraus folgt die ganze Regel:

Der Hook …SchreibweiseWarum
braucht kein Pflichtargument (useFiles, useGroups, useUsers, useCurrentUser)dataSource={useFiles}erfüllt die Signatur () => … schon von sich aus
braucht ein Argument (useFile(id), usePerson(id), useAnnotations(id))const useAkte = () => useFile(id)die id gibt es nur bei dir — der Wrapper schließt sie ein
const { fileId = '' } = useParams();

const useAkte = () => useFile(fileId); // ✅ id aus dem Closure
const useAkteFremd = () => useFile(fileId, 'Mandant'); // ✅ so pinnst du auch den Tenant
warnung
Die Quelle braucht eine eigene Variable — und einen use-Namen

In der Funktion steckt ein Hook. Für ESLint ist sie damit ein Custom Hook, und react-hooks/rules-of-hooks kennt genau zwei Arten, das falsch zu machen:

const akte = () => useFile(id); // ❌ Name ohne use-Präfix
// React Hook "useFile" is called in function "akte" that is neither a React
// function component nor a custom React Hook function.

<Field dataSource={() => useFile(id)} jsonPath="title.de" /> // ❌ inline in JSX
// React Hook "useFile" cannot be called inside a callback.

const useAkte = () => useFile(id); // ✅ benannt + use-Präfix
<Field dataSource={useAkte} jsonPath="title.de" />

Der zweite Fall ist der häufigere: eine Pfeilfunktion direkt in der Prop ist für den Regelsatz ein Callback, egal was drinsteht. Beides läuft zur Laufzeit trotzdem — es sind Lint-Fehler, keine Abstürze. Genau deshalb aber lohnt die Zeile const useAkte = …: Sie ist der einzige Weg, der ohne Lint-Ausnahme auskommt.

useFile oder filesStore.useOne?

Kurze Antwort: das ist dieselbe Funktion. filesStore ist nichts weiter als ein Bündel aus den drei Files-Hooks:

// so ist filesStore in der Library definiert
export const filesStore: ObjectStore = {
useOne: useFile,
useList: useFiles,
useSave: useSaveFile,
};

filesStore.useOne(id) ruft also buchstäblich useFile(id) auf — es gibt keinen Fall, in dem eine dataSource das eine bräuchte und das andere nicht. Es sind zwei Namen für zwei Rollen:

Konkreter HookSlot im ObjectStoreWer ruft das auf
useFile(id, tenant?)useOnedu, in deiner dataSource
useFiles(tenant?)useListdu, direkt als dataSource
useSaveFile(tenant?)useSavedas Widget, beim Speichern
  • Konkrete Namen (useFile, useFiles, useGroups, …) sind für deinen Code gedacht. Du weißt, dass es um Akten geht — also benenne es so.
  • useOne / useList / useSave sind die generischen Slotnamen des Interfaces ObjectStore. Sie existieren, damit ein Widget ein ganzes Bündel annehmen und daraus lesen und schreiben kann, ohne deine Domäne zu kennen. Assignment macht genau das: intern ruft es store.useOne(id) und store.useSave() auf.

Die Faustregel:

// dataSource → konkreter Hook, als benannte Quelle
const useAkte = () => useFile(fileId);
<Field dataSource={useAkte} jsonPath="title.de" />

// store-Prop → das ganze Bündel, das Widget greift selbst nach useOne/useSave
<Assignment type="group" id={fileId} store={filesStore} />
<Assignment type="group" id={fileId} /> {/* filesStore ist der Default */}

filesStore.useOne in eigenem Code zu schreiben lohnt nur, wenn der Store von außen kommt — etwa in einer eigenen Komponente, die über einen beliebigen ObjectStore funktionieren soll:

function Objektkopf({ store, id }: { store: ObjectStore; id: string }) {
const useObjekt = () => store.useOne(id); // hier ist useOne richtig:
return <Field dataSource={useObjekt} jsonPath="displayName" />; // die Domäne ist unbekannt
}
hinweis
useList fordert das Interface, gelesen wird es (noch) nicht

ObjectStore verlangt alle drei Member, Assignment verwendet aber nur useOne und useSave. Für ein eigenes Bündel musst du useList trotzdem angeben, damit der Typ erfüllt ist.

Wer welche Quelle erwartet

WidgetPropErwartetPflicht
FielddataSource() => UseQueryResult<JsonObject>ja
ListdataSource() => UseQueryResult<any[]>ja
BadgedataSource() => UseQueryResult<JsonObject>nein — ohne sie ist value der Text (Literal-Modus)
FileManagerdataSource() => UseQueryResult<JsonObject> — liest daraus $apiIdja
FileManagerdocumentsStoreBündel useByFile + useUploadnein — Default sind die Core-Hooks
AssignmentdataSource() => UseQueryResult<Group[]> (die Auswahlliste)nein — Default useGroups/useUsers je type
AssignmentstoreObjectStore (Lesen und Speichern)nein — Default filesStore
SubmitterdataSourceQuery-Ergebnis oder direkt ein JsonObjectnein

Einmal definieren, überall mitgeben

Der typische Fall: eine Detailansicht, in der mehrere Widgets dieselbe Akte anzeigen. Definiere die Quelle einmal und gib sie weiter:

function AktenDetail() {
const { fileId = '' } = useParams();

const useAkte = () => useFile(fileId); // ← die eine Quelle

return (
<>
<Field dataSource={useAkte} jsonPath="title.de" label="Betreff" />
<Field dataSource={useAkte} jsonPath="formattedNumber" label="Zahl" />
<Badge value="fileHandlingState" dataSource={useAkte} config={statusConfig} />
<FileManager dataSource={useAkte} />
</>
);
}

Jedes dieser vier Widgets ruft useAkte() in seinem eigenen Render auf, es laufen also vier useQuery-Aufrufe. Weil alle denselben Query-Key erzeugen (["file", tenant, id]), hängen sie am selben Cache-Eintrag von React Query: Es entsteht nicht pro Widget ein eigener Ladevorgang, und alle sehen gleichzeitig denselben Zustand (isPending, data, error).

Deshalb ist die naheliegende Sorge unbegründet — mehr Widgets an derselben Quelle bedeuten nicht mehr Requests. Nachprüfen kannst du es im Netzwerk-Tab oder im Mock-Modus an den 🔧-Zeilen in der Konsole.

Was du dabei sehen wirst: useFile läuft mit refetchOnMount: "always", holt die Akte beim Öffnen der Ansicht also bewusst frisch — auch wenn schon etwas im Cache liegt.

Variante: gebundene Komponente

Wenn dir das Wiederholen von dataSource={useAkte} zu viel ist, binde die Quelle per Factory fest an eine Komponente. Das gehört auf Modulebene: die Factory erzeugt einen neuen Komponententyp, und ein im Render erzeugter Typ ist für React jedes Mal ein anderer — der Teilbaum wird neu aufgebaut und verliert seinen State. useMemo drumherum hilft nicht zuverlässig; react-hooks/static-components meldet den Fall deshalb als Fehler („Cannot create components during render").

Braucht die Quelle einen Parameter, holt sie ihn selbst — sie ist ein Hook, darf also useParams oder Context benutzen:

import { createField, createList } from '@gentics/cockpit/widgets';

const AktenListe = createList(useFiles); // Quelle ohne Parameter

function useAkteAusRoute() { // Quelle mit Parameter aus der Route
const { fileId = '' } = useParams();
return useFile(fileId);
}
const AktenFeld = createField(useAkteAusRoute);

function AktenDetail() {
return (
<>
<AktenFeld jsonPath="title.de" label="Betreff" />
<AktenFeld jsonPath="formattedNumber" label="Zahl" />
</>
);
}

Damit useParams greift, muss die Komponente wie gewohnt innerhalb des Routers hängen — die Quelle läuft im Render des Widgets, nicht im Modul-Scope.

Kommt die id dagegen von oben als Prop, passt die Factory nicht: dann bleibt es bei dataSource={useAkte} wie im Abschnitt darüber.

Die gebundenen Komponenten nehmen alle Props des Originals außer dataSource — die Typen dazu heißen BoundFieldProps und BoundListProps.

Statische Objekte: staticSource

Liegen die Daten schon vor, fehlt aber ein Query-Hook, wickelt staticSource das Objekt in eine passende Quelle:

import { staticSource } from '@gentics/cockpit/widgets';

<Field dataSource={staticSource({ user: { name: 'Ada' } })} jsonPath="user.name" />

Zwei Details:

  • staticSource(undefined) bedeutet „lädt noch" (isPending: true) und ergibt ein Skeleton. Das ist praktisch, wenn du die Daten selbst lädst:

    const { data } = useFile(fileId);
    <Field dataSource={staticSource(data)} jsonPath="title.de" /> // Skeleton bis data da ist
  • Fehler transportiert staticSource nicht. Schlägt dein eigener Ladevorgang fehl, bleibt es beim Skeleton bzw. beim Platzhalter — Fehlerbehandlung machst du dann selbst drumherum.

Randfälle

Die Id ist noch nicht da

Nicht jeder Lese-Hook schützt sich gegen eine leere Id — und die entsteht schnell, weil useParams() mit Default (const { fileId = '' } = useParams()) einen Leerstring liefert, solange der Parameter fehlt:

HookVerhalten bei leerer Id
useFile(id)kein Schutz — die Query läuft und der Request geht raus
usePerson(id)enabled: Boolean(id) — bleibt aus
useDocumentsByFile(id, { enabled })enabled bestimmst du; der FileManager setzt selbst enabled: !!fileId

Bei useFile guckst du also selbst hin. Am ehrlichsten ist es, gar nicht zu rendern, solange die Id fehlt:

const { fileId } = useParams();
if (!fileId) return <Navigate to=".." replace />; // vor allen Widgets

const useAkte = () => useFile(fileId);

Ein if zwischen Hook-Aufrufen ist hier unbedenklich, weil es die Komponente verlässt, bevor Hooks laufen — nicht zu verwechseln mit einem bedingten Hook-Aufruf (siehe unten).

Die Quelle darf nicht wechseln

dataSource() läuft in jedem Render — und zwar unbedingt. Damit gelten dort die Hook-Regeln, obwohl es „nur" eine Prop ist:

// ❌ mal der eine, mal der andere Hook → Reihenfolge der Hooks ändert sich
const useQuelle = () => (fileId ? useFile(fileId) : useFiles());

// ❌ bedingter Aufruf
const useQuelle = () => { if (!fileId) return undefined; return useFile(fileId); };

// ✅ ein Hook, immer derselbe — die Bedingung steckt in `enabled` bzw. weiter oben
const useQuelle = () => useFile(fileId);

Dasselbe gilt für die Widgets selbst: Sie wählen ein Store-Bündel (store ?? filesStore) vor dem Aufruf aus und rufen die Hooks danach unbedingt auf. Ein store, den du zur Laufzeit austauschst, verletzt diese Annahme.

Objekt oder Liste — je Widget verschieden

Field, Badge und FileManager erwarten ein Objekt (UseQueryResult<JsonObject>), List ein Array (UseQueryResult<any[]>). Eine Listenquelle passt deshalb nicht in ein Objekt-Widget — TypeScript lehnt dataSource={useFiles} an einem Field ab:

Type 'JsonObject[]' is not assignable to type 'JsonObject'.

Willst du einen einzelnen Eintrag aus einer Liste zeigen, hol ihn heraus und wickle ihn in staticSource:

const { data } = useFiles();

<Field dataSource={staticSource(data?.[0])} jsonPath="title.de" /> // erste Akte
<List dataSource={useFiles} columns={columns} /> // die ganze Liste

Solange data noch undefined ist, bedeutet staticSource(undefined) „lädt noch" — das Field zeigt also von sich aus ein Skeleton.

Der FileManager ist ein Sonderfall: Er liest aus der Quelle nur den Pfad $apiId und lädt damit die Dokumente. Zeigt deine Quelle auf ein Objekt ohne $apiId, bleibt die Dokumentliste leer, ohne dass ein Request scheitert.

Ohne Tenant geht nichts

Alle mandantenbezogenen Hooks lösen den Tenant über den Context auf. Fehlt er — weil der Aufruf außerhalb einer App und damit außerhalb des TenantProvider passiert —, wirft der Hook, statt gegen einen kaputten Pfad zu laufen:

Error: Kein Tenant verfügbar: weder als Parameter übergeben noch aus dem Context.

Innerhalb einer App ist das erledigt; das Sachbearbeiter-Cockpit mountet jede App in ihrem Provider. Für Storybook, Tests oder Vorschauen gibt es zwei Wege: den Tenant explizit übergeben (() => useFile(id, 'MeinMandant')) oder ganz ohne Hook arbeiten und staticSource(obj) verwenden. Mehr dazu unter Mandanten.

Manche Hooks laufen erst auf Kommando

usePersons ist standardmäßig aus und lädt nur mit enabled: true — als dataSource also:

const usePersonenListe = () => usePersons({ enabled: true });

Ohne das bleibt die Liste leer, auch im Mock-Modus, und es gibt keinen Fehler zu sehen.

Wer Fehler anzeigt — und wer nicht

Lade- und Fehlerzustände stecken im Query-Ergebnis; was daraus wird, entscheidet das Widget:

WidgetWährend des LadensWenn die Quelle scheitert
FieldSkeletonPlatzhalter keine Fehlermeldung
List5 Skeleton-Zeilen„Keine Einträge gefunden." wie bei leerem Ergebnis
Badgeleerer Platzhalterleerer Platzhalter
SubmitterSkeleton im KopfKopf bleibt dauerhaft im Skeleton, Zusatzfelder zeigen
FileManagerSkeletonEntwickler-Hinweis zum fehlenden $apiId

Beim FileManager lohnt die Unterscheidung: Die rote Meldung „Dokumente konnten nicht geladen werden." samt „Erneut versuchen" gilt der Dokumenten-Query. Scheitert dagegen die dataSource, findet das Widget kein $apiId und zeigt denselben Entwickler-Hinweis wie bei einem falschen Pfad.

Ein fehlgeschlagener Request sieht also fast überall aus wie „keine Daten" — nirgends steht die eigentliche Fehlermeldung. Wenn dir das zu still ist, prüfe den Zustand selbst; du hast dieselbe Query in der Hand:

const { isError, error } = useFile(fileId);
if (isError) return <p role="alert">Akte nicht ladbar: {error.message}</p>;

Das kostet keinen zweiten Request: es ist derselbe Query-Key wie in useAkte.