Daten & Stores
Der Datenzugriff hat zwei Schichten, und die Trennung ist bewusst:
| Schicht | Was | Wofür |
|---|---|---|
| Services | Klassen (FilesService, DocumentsService, …) ohne React | Ein Aufruf gegen die ActaNova-API. Imperativ nutzbar, auch außerhalb von Komponenten. |
| Stores | React-Query-Hooks (useFiles, useFile, …) | Dieselben Aufrufe mit Cache, Deduplizierung, Lade-/Fehlerzuständen und Invalidierung. |
In Komponenten nimmst du praktisch immer die Hooks. Zu einem Service greifst du, wenn du außerhalb von React etwas laden oder schreiben musst.
Wohin die Aufrufe gehen
Alle Services rufen relativ zur Base-URL /api/cockpit/an auf, die
mandantenbezogenen Endpunkte zusätzlich unter /Tenants/Items/<tenant>:
/api/cockpit/an/Tenants/Items/<tenant>/Files/Items?$top=100
/api/cockpit/an/Me
Dein Host muss diesen Pfad weiterleiten. Jeder Aufruf
trägt automatisch Authorization: Bearer <access_token>; zur Fehler- und
Erneuerungslogik siehe Auth & Rechte.
Die Hooks im Überblick
Alle mandantenbezogenen Hooks nehmen den Tenant optional als letzten Parameter; ohne Angabe kommt er aus dem Context.
| Hook | Signatur | Query-Key | Besonderheit |
|---|---|---|---|
useFiles | (tenant?) | ["files", tenant] | lädt bis zu 100 Einträge ($top=100); Akten mit fileHandlingState === "Destroyed" werden ausgefiltert |
useFile | (id, tenant?) | ["file", tenant, id] | refetchOnMount: "always" — beim Öffnen der Detailansicht immer frisch |
useSaveFile | (tenant?) | Mutation { id, file } | PATCH als merge-patch+json; invalidiert danach ["files", tenant] und ["file", tenant, id] |
useGroups | (tenant?) | ["groups", tenant] | placeholderData: []; liefert Group[] (Name aus name.de) |
useUsers | (tenant?) | ["users", tenant] | placeholderData: []; liefert Group[] (Name aus displayName) |
useCurrentUser | () | ["me"] | nicht mandantenbezogen; staleTime: Infinity |
usePersons | ({ enabled?, tenant? }) | ["persons", tenant] | läuft nur mit enabled: true; staleTime: 60 s |
usePerson | (id, { tenant? }) | ["person", tenant, id] | läuft nur mit gesetzter id; staleTime: 60 s |
useAnnotations | (id, tenant?) | ["annotations"] | placeholderData: [] |
useDocumentsByFile | (fileId, { enabled? }, tenant?) | ["documents", tenant, fileId] | enabled steuert, ob geladen wird |
useUploadDocument | (tenant?) | Mutation { fileId, file } | invalidiert danach ["documents", tenant, fileId] |
useCompleteActivity | (tenant?) | Mutation activityId | invalidiert ["workListActivities"] und ["workListActivity"] |
Die Services und ihre Endpunkte
| Service | Methode | Aufruf |
|---|---|---|
FilesService | getFiles(tenant) | GET …/Files/Items?$top=100 |
getFileById(tenant, id) | GET …/Files/Items/{id} | |
saveFileById(tenant, id, file) | PATCH …/Files/Items/{id} | |
DocumentsService | getDocumentsByFile(tenant, fileId) | GET …/Files/Items/{fileId}/Documents/Items |
uploadDocument(tenant, fileId, file) | zwei Schritte, siehe unten | |
getDefaultDocumentTypeId(tenant) | GET …/DocumentClassificationTypes/ItemReferences | |
getDefaultLeadingGroupId() | GET /Me/Groups/Items (erste Gruppe) | |
GroupService | getGroups(tenant) | GET …/Groups/Items |
UserService | getMe() | GET /Me |
getUsers(tenant) | GET …/Users/Items | |
getMyGroups() | GET /Me/Groups/Items | |
PersonsService | getPersons(tenant) | GET …/Persons/Items/ |
getPersonById(tenant, id) | GET …/Persons/Items/{id} | |
AnnotationsService | getAnnotations(tenant, fileId) | GET …/Files/Items/{fileId}/Annotations/Items |
ActivitiesService | completeActivity(tenant, id) | POST …/Activities/Items/{id}/Complete |
Die Services normalisieren die Antworten: die übliche { data: [...] }-Hülle
wird ausgepackt, Gruppen und User auf die Form Group
(name, $apiId, referencedApiType) gebracht, Dokumente auf
DocumentSummary (name, size in Bytes, url — die zeigt auf den aktiven
Inhalt aus $apiLinks.getActiveContent).
Der Upload ist zweistufig
ActaNova nimmt eine Datei nicht in einem Rutsch an. uploadDocument erledigt
beides hintereinander — du rufst trotzdem nur eine Methode auf:
0. Vorgabe-Ids auflösen DocumentType + Leading Group (gecacht)
1. POST …/ContentObjects/CreateTemporary → temporäres ContentObject
2. POST …/Documents/Items → Document, das ContentObject + Akte verknüpft
Schritt 0 läuft absichtlich zuerst und parallel: schlägt ein Lookup fehl, entsteht kein verwaistes temporäres ContentObject. Zwei Ids werden gebraucht:
| Id | Woher |
|---|---|
| DocumentType | GET …/DocumentClassificationTypes/ItemReferences, daraus der Eintrag mit referenceId === "!DocumentClassificationType!CommonDocumentType" — so bleibt die tenant-spezifische $apiId unhartkodiert |
| Leading Group | erste Gruppe aus GET /Me/Groups/Items |
Gecacht wird das Promise, nicht der Wert: parallele Uploads teilen sich
denselben laufenden Request. Fehlschläge landen nicht im Cache, der nächste
Versuch fragt also neu. Der DocumentType-Cache gilt pro Tenant, die Leading
Group pro Session (/Me/… ist user-, nicht mandantenbezogen). Wer den Lookup
vom ersten Upload entkoppeln will, ruft getDefaultDocumentTypeId(tenant) schon
beim Mount der App auf — nötig ist das nicht.
Schritt 1 schickt die Datei als FormData (Feld formFile) an
CreateTemporary. Schritt 2 legt das Document an und verknüpft darin
parentObject (die Akte), contentObject (aus Schritt 1), documentType und
leadingGroup; name ist der Dateiname, physicallyPresent ist false.
Fehler aus diesem Ablauf, die im error der Mutation landen:
| Meldung | Bedeutet |
|---|---|
No DocumentClassificationType with referenceId "…" found for tenant "…" | Schritt 0: der Standard-Dokumenttyp fehlt für diesen Mandanten |
No group with $apiId returned from /Me/Groups/Items — cannot determine leading group | Schritt 0: der User ist in keiner Gruppe |
No $apiId returned from CreateTemporary endpoint | Schritt 1: das Backend hat kein temporäres ContentObject geliefert |
Im Mock-Modus entfallen beide Requests; die Datei landet in einem In-Memory-Bestand und taucht beim nächsten Laden der Liste tatsächlich auf.
Eigene Endpunkte ohne eigenen Service
Für alles, was die Library nicht abdeckt, gibt es makeCall auf jedem Service.
Base-URL, Tenant-Prefix und Auth-Header kommen automatisch — du bestimmst nur
den Pfad dahinter:
import { BaseService } from '@gentics/cockpit/core';
const api = new BaseService();
// GET
const akten = await api.makeCall('/Files/Items?$top=100');
// PATCH mit Body
await api.makeCall(`/Files/Items/${id}`, {
method: 'PATCH',
body: { remark: 'geprüft' },
headers: { 'Content-Type': 'application/merge-patch+json' },
});
Den Tenant musst du nicht mitgeben: makeCall nimmt den aktiven Tenant der
laufenden App — denselben, den useTenant() liefert. Nur wenn du bewusst gegen
einen anderen Mandanten willst, setzt du ihn:
await api.makeCall('/Files/Items', { tenant: 'anderer-mandant' });
await api.makeCall(tenant, '/Files/Items'); // ältere Form, weiterhin gültig
Läuft der Aufruf außerhalb jeder App — also ohne
TenantProvider, etwa auf der Sachbearbeiter-Cockpit-Übersicht —,
gibt es keinen aktiven Tenant. Dann wirft makeCall, statt gegen einen kaputten
Pfad zu laufen:
Error: Kein Tenant verfügbar: weder übergeben noch aus der laufenden App.
FormData als Body wird erkannt und ohne Content-Type gesendet, damit der
Browser die Boundary setzt.
In einer Komponente wickelst du das in React Query — dann verhält sich dein Aufruf wie die eingebauten Hooks. Den Tenant holst du dir hier trotzdem per Hook, weil er in den Query-Key gehört:
import { useQuery } from '@tanstack/react-query';
import { BaseService, useTenant } from '@gentics/cockpit/core';
const api = new BaseService();
export function useAktenTypen() {
const tenant = useTenant();
return useQuery({
queryKey: ['fileTypes', tenant],
queryFn: () => api.makeCall<{ data: unknown[] }>('/FileTypes/Items'),
});
}
Braucht dein Aufruf mehr als einen Endpunkt oder eigene Normalisierung, leite
stattdessen von BaseService ab — die geschützten Helfer apiGet, apiPost,
apiPatch und apiPostFormData stehen dann zur Verfügung. Willst du dort
denselben Automatismus, nimm this.resolveTenant(tenant?): gibt den übergebenen
Tenant zurück oder den aktiven der App. Außerhalb von React geht auch
getActiveTenant() — das liefert null, wenn keine App läuft.
Eigene Stores anbinden
Zwei Widgets arbeiten nicht mit einer einzelnen Query, sondern mit einem
Bündel aus Lesen und Schreiben. Das kannst du ersetzen — damit lassen sich
die Widgets auf andere Objekttypen als File richten.
ObjectStore für Assignment
import type { ObjectStore } from '@gentics/cockpit/core';
const antraegeStore: ObjectStore = {
useOne: (id, tenant) => useAntrag(id, tenant),
useList: (tenant) => useAntraege(tenant),
useSave: (tenant) => useSaveAntrag(tenant),
};
<Assignment type="group" id={antragId} store={antraegeStore} />
Das Speichern kommt immer aus demselben Bündel — eine separate Save-Prop gibt es
nicht. Ohne store nutzt Assignment den fertigen filesStore (die drei
Files-Hooks). Die drei Member müssen diese Signaturen erfüllen:
| Member | Signatur |
|---|---|
useOne | (id: string, tenant?: string) => UseQueryResult<JsonObject, Error> |
useList | (tenant?: string) => UseQueryResult<JsonArray, Error> |
useSave | (tenant?: string) => UseMutationResult<boolean, Error, { id: string; file: JsonObject }> |
Assignment schreibt die Auswahl je nach type nach leadingGroup bzw.
leadingUser — dein Objekt muss diese Felder also kennen.
DocumentsStore für den FileManager
const store = {
useByFile: (fileId, options, tenant) => useMeineDokumente(fileId, options, tenant),
useUpload: (tenant) => useMeinUpload(tenant),
};
<FileManager dataSource={quelle} documentsStore={store} />
Default ist DEFAULT_DOCUMENTS_STORE (useDocumentsByFile +
useUploadDocument). Welche Felder die Bündel liefern müssen, steht unter
DocumentsStore.
Fehler
BaseService wirft bei jeder Antwort, die nicht ok ist:
Error: API Error: 404 Not Found
Über die Hooks landet das im error des Query-Ergebnisses — behandle es also
wie bei jedem React-Query-Hook (isError, error). 204 No Content wird als
true zurückgegeben, andere leere Antworten als undefined.
Weiter
- Datenquellen — wie die Daten in die Widgets kommen
- Mandanten — der
tenantin Pfad und Query-Key - Mock-Modus — ohne Backend arbeiten
