Erste App bauen
Wir bauen die App aus Sachbearbeiter-Cockpit aufsetzen — eine Aktenliste mit Detailansicht. Am Ende steht eine App, die Daten lädt, anzeigt, navigiert und eine Zuweisung speichert.
Innerhalb der App ist alles schon bereitgestellt: React Query, der aktive Mandant und der Router. Du kannst die Store-Hooks also direkt aufrufen.
Das Grundgerüst
Eine App ist eine gewöhnliche React-Komponente. Der Typ AppComponent
beschreibt, was sie bekommt ({ app }):
// src/apps/akten/AktenApp.tsx
import { Routes, Route } from 'react-router';
import type { AppComponent } from '@gentics/cockpit/core';
import { AktenListe } from './AktenListe';
import { AktenDetail } from './AktenDetail';
export const AktenApp: AppComponent = ({ app }) => (
<div className="p-6">
<h1 className="mb-4 text-xl font-bold">{app.title}</h1>
{/* Eigene, RELATIVE Routen der App. Das Sachbearbeiter-Cockpit hat die App
unter /:appId/* gemountet – hier gibt es keine führenden Slashes. */}
<Routes>
<Route index element={<AktenListe />} />
<Route path=":fileId" element={<AktenDetail />} />
</Routes>
</div>
);
Mehr zu den Routen — auch wie tief du sie schachteln kannst — steht unter Routing in Apps.
Die Liste
useFiles() lädt die Akten des aktiven Mandanten, List zeigt sie an. Die
Spalten sind Konfiguration, keine JSX:
// src/apps/akten/AktenListe.tsx
import { useNavigate } from 'react-router';
import { useFiles, type IListColumns } from '@gentics/cockpit/core';
import { List } from '@gentics/cockpit/widgets';
const columns: IListColumns[] = [
{ label: 'Betreff', path: 'title.de', width: 4 },
{ label: 'Zahl', path: 'formattedNumber', width: 2 },
{ label: 'Art', path: 'fileType.displayName', width: 2, hasFilter: true },
{
label: 'Status',
path: 'fileHandlingState',
width: 2,
type: 'badge',
hasFilter: true,
badgeConfig: {
Work: { label: 'In Bearbeitung', color: '#137DFF' },
Closed: { label: 'Abgeschlossen', color: '#1A7F37' },
},
},
{ label: 'Angelegt', path: 'openingDate', width: 2, type: 'date' },
];
export function AktenListe() {
const navigate = useNavigate();
return (
<List
columns={columns}
dataSource={useFiles}
defaultSort={{ path: 'openingDate', direction: 'desc' }}
onItemClick={(item) => navigate(item.$apiId)}
/>
);
}
Drei Dinge sind hier wichtig:
dataSourceist eine Funktion, kein Ergebnis.Listruft sie beim Rendern selbst auf.dataSource={useFiles}ist deshalb richtig,dataSource={useFiles()}falsch. UnddataSource={() => useFiles()}läuft zwar, ist aber ein Lint-Fehler — ein Hook darf nicht in einer Inline-Funktion stehen, mehr dazu unter Datenquellen.- Freitextsuche, Spaltenfilter und Sortierung bringt
Listmit. Filter erscheinen für jede Spalte mithasFilter: true; Sortierung und Filterwahl werden imlocalStoragegemerkt. navigate(item.$apiId)ist relativ und landet damit auf/akten/<id>— genau der Route:fileIdvon oben.
Die Detailansicht: eine Datenquelle für alles
In der Detailansicht brauchen mehrere Widgets dieselbe Akte. Definiere die Quelle einmal und gib sie überall mit — React Query bündelt die Aufrufe über den Query-Key zu einem Request:
// src/apps/akten/AktenDetail.tsx
import { useParams } from 'react-router';
import { useFile } from '@gentics/cockpit/core';
import { Badge, Field, FileManager, Assignment } from '@gentics/cockpit/widgets';
export function AktenDetail() {
const { fileId = '' } = useParams();
// EINE Datenquelle – an jedes Widget weitergegeben. Der Name beginnt mit
// `use`, weil ein Hook darin steckt (sonst meckert react-hooks/rules-of-hooks).
const useAkte = () => useFile(fileId);
return (
<div className="flex flex-col gap-6 lg:flex-row">
<div className="flex-1">
<Field dataSource={useAkte} jsonPath="title.de" label="Betreff" />
<Field dataSource={useAkte} jsonPath="formattedNumber" label="Zahl" />
<Field dataSource={useAkte} jsonPath="openingDate" label="Angelegt am" />
<Badge
value="fileHandlingState"
dataSource={useAkte}
config={{ Work: { label: 'In Bearbeitung', color: '#137DFF' } }}
/>
</div>
<div className="w-full lg:w-96">
{/* Liest die Kontext-Id selbst als `$apiId` aus der Quelle. */}
<FileManager dataSource={useAkte} accept=".pdf,image/*" maxFiles={10} />
{/* Speichert über den Standard-Store (filesStore) – kein Save-Prop nötig. */}
<Assignment type="group" id={fileId} />
</div>
</div>
);
}
Drei Dinge, über die man hier typischerweise stolpert:
- Warum
() => useFile(fileId)und nichtuseFiledirekt? Weil die WidgetsdataSource()ohne Argumente aufrufen.useFilespasst deshalb direkt in die Prop,useFilebraucht diefileId— die kommt aus dem Closure. useFileoderfilesStore.useOne? Das ist dieselbe Funktion:filesStoreist nur ein Bündel{ useOne: useFile, useList: useFiles, useSave: useSaveFile }. In eigenem Code nimm den konkreten Namen;useOnebrauchst du erst, wenn ein Widget ein ganzes Bündel bekommt (store-Prop).- Doppeltes Laden gibt es nicht. Alle vier Widgets erzeugen denselben Query-Key und hängen damit am selben Cache-Eintrag.
Alle drei Punkte im Detail — samt Randfällen wie leerer Id, enabled und
Fehleranzeige — stehen unter Datenquellen.
Lade- und Fehlerzustände
Darum musst du dich meist nicht kümmern: Field, List und Submitter zeigen
von sich aus ein Skeleton, solange die Query isPending oder
isPlaceholderData meldet (der Badge einen leeren Platzhalter). Der
FileManager zeigt zusätzlich eine Fehlermeldung mit „Erneut versuchen".
Zwei Fälle solltest du kennen, weil sie beim Entwickeln auffallen:
| Situation | Was du siehst |
|---|---|
Pfad existiert, Wert ist null | Platzhalter — |
| Pfad existiert nicht im Objekt | roter Entwickler-Hinweis Field.tsx – Could not find <pfad> for the given data |
Der zweite Fall ist Absicht: ein Tippfehler im jsonPath soll auffallen und
nicht als leeres Feld durchgehen.
Speichern und Rückmeldung geben
Zum Schreiben nimmst du die Mutation aus dem Store; sie invalidiert danach die betroffenen Queries selbst, sodass die Anzeige aktuell wird. Für Meldungen gibt es den Toast-Kanal der Widgets:
import { useState } from 'react';
import { useFile, useSaveFile } from '@gentics/cockpit/core';
import { widgetToast, ToastHost } from '@gentics/cockpit/widgets';
function AnmerkungBearbeiten({ fileId }: { fileId: string }) {
const { data: akte } = useFile(fileId);
const save = useSaveFile();
const [remark, setRemark] = useState('');
const onSave = async () => {
try {
// PATCH als merge-patch+json: die geänderten Felder genügen.
await save.mutateAsync({ id: fileId, file: { remark } });
widgetToast.success('Gespeichert');
} catch {
widgetToast.error('Speichern fehlgeschlagen');
}
};
return (
<div>
<textarea
defaultValue={(akte?.remark as string) ?? ''}
onChange={(e) => setRemark(e.target.value)}
/>
<button onClick={onSave} disabled={save.isPending}>
{save.isPending ? 'Speichere …' : 'Speichern'}
</button>
{/* Widgets bringen den Host selbst mit; ein zusätzlicher an der Wurzel
ist unschädlich – und nötig, wenn du selbst Toasts auslöst. */}
<ToastHost />
</div>
);
}
Nach erfolgreichem useSaveFile() invalidiert der Store ["files", tenant] und
["file", tenant, id] — die Liste und alle Widgets, die an derselben Quelle
hängen, aktualisieren sich also von selbst.
Field schreibt nicht zurück<Field editable /> rendert ein Input-Feld, gibt Änderungen aber nicht an
einen Store weiter — es gibt derzeit keinen onChange-Weg. Zum Speichern
brauchst du wie oben ein eigenes Formular plus useSaveFile().
Weiter
- Routing in Apps — Unterseiten, Deep-Links, Router-Modi
- Datenquellen — das
dataSource-Muster im Detail - Daten & Stores — alle Hooks, eigene Endpunkte
- Mandanten — mehrere Tenants pro App
