React-Admin-Erweiterungen

Auf dieser Seite

Native Plugins können vertrauenswürdige React-Komponenten in die EmDash-Adminoberfläche laden. Der Host behält die Kontrolle über Navigation, Seitenrouting, Dashboard-Karten, Editor-Layout, Content-Tabellen, Authentifizierung und Fehlergrenzen. Das Plugin liefert die Komponenten und die Metadaten, die für ihre Platzierung nötig sind.

Wenn das Plugin nur ein Einstellungsformular braucht, beginne mit admin.settingsSchema. Es verwendet die Formular-Komponenten des Hosts und benötigt keinen React-Einstiegspunkt. Auch Sandbox-Plugins können dieses generierte Formular nutzen; die benutzerdefinierten React-Erweiterungen auf dieser Seite erfordern ein natives Plugin.

Generiertes Einstellungsformular

Deklariere settingsSchema in der Runtime-Definition. Das folgende Schema erzeugt ein mehrzeiliges Textfeld, ein Select, eine Zahleneingabe, einen Switch und eine schreibgeschützte Secret-Eingabe:

return definePlugin({
	id: "plugin-activity",
	version: "0.1.0",
	admin: {
		settingsSchema: {
			projectName: {
				type: "string",
				label: "Project name",
				description: "Name shown in activity exports",
			},
			notes: {
				type: "string",
				label: "Internal notes",
				multiline: true,
			},
			mode: {
				type: "select",
				label: "Recording mode",
				options: [
					{ value: "creates", label: "New entries only" },
					{ value: "all", label: "New and updated entries" },
				],
				default: "all",
			},
			retentionDays: {
				type: "number",
				label: "Retention in days",
				min: 1,
				max: 365,
				default: 30,
			},
			enabled: {
				type: "boolean",
				label: "Record activity",
				default: true,
			},
			exportToken: {
				type: "secret",
				label: "Export token",
			},
		},
	},
});

Die verfügbaren Felder haben die folgenden Optionen. label ist erforderlich und description ist für jeden Typ optional.

typeWertZusätzliche Felder
stringstringdefault, multiline
numbernumberdefault, min, max
booleanbooleandefault
selectstringerforderliches options: Array<{ value, label }> und optionales default
secretstringkeine zusätzlichen Felder; der gespeicherte Wert wird nie an den Browser zurückgegeben
urlstringdefault, placeholder
emailstringdefault, placeholder

Das Formular ist über die Einstellungssteuerung auf der Plugin-Karte unter Plugins erreichbar. Lesen oder Ändern erfordert plugins:manage.

Einstellungen nutzen den namensraumisolierten Settings-Store des Plugins. Ein Feld namens retentionDays ist für das Plugin als retentionDays verfügbar:

const retentionDays =
	(await ctx.settings.get<number>("retentionDays")) ?? 30;

Schema-Defaults füllen das generierte Formular, wenn kein Wert gespeichert ist, aber EmDash schreibt diese Defaults nicht in den Settings-Store. Wende denselben Fallback an, wenn die Runtime die Einstellung liest. Das Leeren eines Nicht-Secret-Feldes löscht seinen gespeicherten Wert und setzt das Formular auf den Default zurück. Secret-Werte werden vor der Persistenz verschlüsselt und nie an den Browser zurückgesendet; das Formular meldet nur, ob ein Secret gesetzt ist, und lässt einen Administrator es ersetzen oder löschen.

Einstellungslabels und -beschreibungen werden wie deklariert gerendert. Wenn sich diese Strings mit der Admin-Locale ändern müssen, baue stattdessen eine benutzerdefinierte React-Einstellungsseite.

React-Einstiegspunkt

Eine vertrauenswürdige React-Erweiterung hat drei zusammenhängende Deklarationen:

  1. Das adminEntry des Descriptors sagt Astro, welches Modul in die Adminoberfläche gebündelt werden soll.
  2. Die Runtime-Felder admin.entry, admin.pages und admin.widgets beschreiben die sichtbaren Admin-Oberflächen.
  3. Das Admin-Modul exportiert Komponenten-Maps, deren Schlüssel mit den deklarierten Seitenpfaden und Widget-IDs übereinstimmen. Feld-Widgets sind die Ausnahme: Sie werden über den Namen aus dem Export fields aufgelöst und brauchen keine Deklaration.

Der Descriptor braucht nur den Modul-Specifier. Seiten- und Widget-Metadaten gehören in die Runtime-Definition:

export function activityPlugin(): PluginDescriptor {
	return {
		id: "plugin-activity",
		version: "0.1.0",
		format: "native",
		entrypoint: "@example/plugin-activity",
		adminEntry: "@example/plugin-activity/admin",
	};
}

export function createPlugin() {
	return definePlugin({
		id: "plugin-activity",
		version: "0.1.0",
		storage: {
			events: { indexes: ["createdAt"] },
		},
		admin: {
			entry: "@example/plugin-activity/admin",
			pages: [{ path: "/activity", label: "Activity", icon: "clock" }],
			widgets: [{ id: "recent-activity", title: "Recent activity" }],
		},
	});
}

Halte adminEntry und admin.entry identisch. Das Erste ist ein Build-Zeit-Import; das Zweite sagt der Runtime, dass das Plugin vertrauenswürdige React-Admin-Komponenten verwendet.

Admin-Seiten

Jede Seitendeklaration hat die folgenden Felder:

FeldPflichtVerhalten
pathJaMountet die Seite unter /_emdash/admin/plugins/<plugin-id><path>. Verwende einen führenden Schrägstrich.
labelJaLiefert die Beschriftung für die Seitenleiste und die Befehlspalette.
iconNeinBenennt ein Phosphor-Icon in Kebab-, Snake-, durch Leerzeichen getrennter oder PascalCase-Schreibweise. Unbekannte Namen fallen auf das Plugin-Icon zurück.
groupNeinPlatziert die Seite in einem einklappbaren Ordner der Seitenleiste. Entspricht eine Gruppe der Gruppe einer in der Seitenleiste angezeigten Collection (Groß-/Kleinschreibung wird beachtet, umgebende Leerzeichen werden ignoriert), landet die Seite in diesem Ordner nach den Collections und Taxonomien. Andernfalls bilden Seiten mit derselben Gruppe, auch aus verschiedenen Plugins, einen Ordner im Bereich „Plugins“.

Das Admin-Modul mappt jeden deklarierten Pfad auf eine React-Komponente. Ein trailing slash wird als gleichwertig behandelt, und die Plugin-Root öffnet die erste exportierte Seite, wenn keine /-Seite existiert.

Die folgende Seite lädt eine private Plugin-Route. Verwende Kumo für Steuerelemente und apiFetch() für Plugin-API-Anfragen; apiFetch() fügt den Header X-EmDash-Request: 1 hinzu, den cookie-authentifizierte private Routen benötigen.

import { Button, Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
import * as React from "react";

interface ActivitySummary {
	count: number;
}

export function ActivityPage() {
	const { i18n } = useLingui();
	const [summary, setSummary] = React.useState<ActivitySummary>();
	const [error, setError] = React.useState<string>();

	const load = React.useCallback(async () => {
		setError(undefined);
		try {
			const response = await apiFetch(
				"/_emdash/api/plugins/plugin-activity/summary",
			);
			setSummary(
				await parseApiResponse<ActivitySummary>(
					response,
					i18n._({ id: "activity.load-error", message: "Could not load activity" }),
				),
			);
		} catch (cause) {
			setError(cause instanceof Error ? cause.message : String(cause));
		}
	}, [i18n]);

	React.useEffect(() => {
		void load();
	}, [load]);

	return (
		<section className="space-y-4">
			<h1 className="text-2xl font-semibold">
				{i18n._({ id: "activity.title", message: "Activity" })}
			</h1>
			{summary ? (
				<p>
					{i18n._({ id: "activity.count", message: "Event count" })}: {summary.count}
				</p>
			) : error ? (
				<p role="alert" className="text-kumo-danger">{error}</p>
			) : (
				<Loader />
			)}
			<Button type="button" onClick={() => void load()}>
				{i18n._({ id: "activity.refresh", message: "Refresh" })}
			</Button>
		</section>
	);
}

Definiere die entsprechende Route in der nativen Runtime. Native Handler erhalten ein Kontext-Argument:

routes: {
	summary: {
		permission: "plugins:read",
		handler: async (ctx) => ({
			count: await ctx.storage.events.count(),
		}),
	},
},

Private Routen verwenden standardmäßig die nur für Administratoren geltende Berechtigung plugins:manage. Deklariere die engste bestehende Berechtigung, die zur Operation passt. Verwende public: true nur für einen Endpunkt, der für nicht authentifizierten Internetverkehr gedacht ist.

Exportiere die Seite aus dem Admin-Einstiegspunkt:

import type { PluginAdminExports } from "emdash";

import { ActivityPage } from "./ActivityPage.js";

export const pages: PluginAdminExports["pages"] = {
	"/activity": ActivityPage,
};

Seitenlabels werden über die gemeinsame Lingui-Instanz der Adminoberfläche geleitet. Ein Label wie Settings verwendet die Admin-Übersetzung, wenn eine existiert. Ein Plugin kann seinen eigenen Message-Katalog in die gemeinsame Instanz laden für pluginspezifische Labels und Komponentenmeldungen; andernfalls ist die deklarierte englische Message der Fallback.

Lade den Plugin-Katalog, wenn der Admin-Einstiegspunkt importiert wird, und lade ihn erneut, nachdem der Administrator die Locale geändert hat. Der folgende kleine deutsche Katalog verwendet dieselben IDs wie die Seiten- und Widget-Beispiele:

import { i18n } from "@lingui/core";

const catalogs: Record<string, Record<string, string>> = {
	de: {
		Activity: "Aktivität",
		"activity.title": "Aktivität",
		"activity.count": "Ereignisanzahl",
		"activity.refresh": "Aktualisieren",
		"activity.load-error": "Aktivität konnte nicht geladen werden",
		"activity.unavailable": "Nicht verfügbar",
		"activity.default-locale": "Standardsprache",
	},
};

function loadPluginCatalog() {
	const messages = catalogs[i18n.locale];
	if (!messages || "activity.title" in i18n.messages) return;
	i18n.load(i18n.locale, messages);
}

loadPluginCatalog();
i18n.on("change", loadPluginCatalog);

Importiere den Loader wegen seines Registrierungs-Nebeneffekts, bevor du Komponenten exportierst:

import "./i18n.js";

// Page, widget, panel, and column exports follow.

Die Adminoberfläche ersetzt ihren aktiven Katalog, wenn sich die Locale ändert. Der change-Listener stellt die Plugin-Messages wieder her, und die Message-ID-Prüfung verhindert, dass i18n.load() eine Schleife auslöst. Für weitere Locales generiere die Message-Objekte mit dem Lingui-Build des Plugins, anstatt sie von Hand zu pflegen. Halte @lingui/core und @lingui/react als Peer-Dependencies, damit das Plugin die gemeinsame Host-Instanz verwendet.

Dashboard-Widgets

Eine Widget-Deklaration hat eine erforderliche id und optionale title und size:

admin: {
	entry: "@example/plugin-activity/admin",
	widgets: [
		{ id: "recent-activity", title: "Recent activity", size: "half" },
	],
},

Exportiere eine Komponente unter derselben ID. Dieses Widget liest dieselbe Summary-Route wie die Seite und liefert nur den Karteninhalt; EmDash liefert die umgebende Dashboard-Karte und die Überschrift.

import { useLingui } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import type { PluginAdminExports } from "emdash";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";

interface ActivitySummary {
	count: number;
}

async function loadSummary(fallbackMessage: string) {
	const response = await apiFetch(
		"/_emdash/api/plugins/plugin-activity/summary",
	);
	return parseApiResponse<ActivitySummary>(
		response,
		fallbackMessage,
	);
}

function RecentActivityWidget() {
	const { i18n } = useLingui();
	const { data, isLoading, isError } = useQuery({
		queryKey: ["plugin-activity", "summary"],
		queryFn: () =>
			loadSummary(
				i18n._({ id: "activity.load-error", message: "Could not load activity" }),
			),
	});

	return (
		<p>
			{i18n._({ id: "activity.count", message: "Event count" })}:{" "}
			{isLoading
				? "…"
				: isError
					? i18n._({ id: "activity.unavailable", message: "Unavailable" })
					: (data?.count ?? 0)}
		</p>
	);
}

export const widgets: PluginAdminExports["widgets"] = {
	"recent-activity": RecentActivityWidget,
};

Der Host platziert die Komponente in einer Dashboard-Karte und rendert title als Überschrift. Halte die Komponente kompakt und füge keine zweite Kartenschale hinzu. size akzeptiert full, half oder third; es wird als Layout-Hinweis gespeichert, aber das aktuelle Dashboard rendert Plugin-Widgets in seinem responsiven Zwei-Spalten-Raster, ohne diesen Hinweis anzuwenden.

Benutzerdefinierte Feld-Widgets

Ein Feld-Widget ersetzt den Editor eines Schemafelds durch eine Komponente aus deinem Plugin. Exportiere aus dem Admin-Modul eine fields-Map, die nach Widget-Namen indiziert ist:

import { useLingui } from "@lingui/react";

interface FieldWidgetProps {
	value: unknown;
	onChange: (value: unknown) => void;
	label: string;
	id: string;
	required?: boolean;
	options?: Record<string, unknown> | Array<{ value: string; label: string }>;
	validation?: Record<string, unknown>;
	minimal?: boolean;
}

function RatingField({ value, onChange, label, id, options }: FieldWidgetProps) {
	const { i18n } = useLingui();
	const max = !Array.isArray(options) && typeof options?.max === "number" ? options.max : 5;
	const rating = typeof value === "number" ? value : 0;

	return (
		<div role="group" aria-labelledby={`${id}-label`} className="grid gap-2">
			<span id={`${id}-label`} className="text-sm font-medium">
				{label}
			</span>
			<div className="flex gap-1">
				{Array.from({ length: max }, (_, index) => index + 1).map((step) => (
					<button
						key={step}
						type="button"
						id={step === 1 ? id : undefined}
						aria-pressed={step <= rating}
						aria-label={i18n._({
							id: "rating.set",
							message: "Rate {step}",
							values: { step },
						})}
						onClick={() => onChange(step === rating ? null : step)}
					>
						{step <= rating ? "★" : "☆"}
					</button>
				))}
			</div>
		</div>
	);
}

export const fields = {
	rating: RatingField,
};

Das Plugin braucht das in React-Einstiegspunkt beschriebene Paar adminEntry und admin.entry, damit das Modul geladen wird. Nichts sonst im Descriptor oder im Aufruf von definePlugin() verweist auf ein Feld-Widget.

Ein Schemafeld meldet sich an, indem widget auf <plugin-id>:<widget-name> gesetzt wird. Die Plugin-ID ist die id, die an definePlugin() übergeben wird, und der Widget-Name ist ein Schlüssel des Exports fields. Das folgende Seed-Feld verwendet das obige Widget für ein Plugin mit der ID plugin-reviews:

{
	"slug": "rating",
	"label": "Rating",
	"type": "integer",
	"widget": "plugin-reviews:rating",
	"options": { "max": 5 }
}

Die Schema-API akzeptiert widget ebenfalls. Wähle einen Feld-type, der den vom Widget erzeugten Wert aufnehmen kann; das Widget ändert nur den Editor, nicht die Art, wie der Wert gespeichert wird.

Die Komponente erhält die folgenden Props:

PropTypVerhalten
valueunknownDer aktuelle Wert des Felds im Editor. Grenze den Typ ein, bevor du ihn verwendest.
onChange(value: unknown) => voidMit dem neuen Wert aufrufen, nicht mit einem Event.
labelstringDie Beschriftung des Felds. Der Host rendert um ein Plugin-Widget keine eigene, also rendere sie selbst.
idstringEine DOM-ID für das Feld in der Form field-<slug>.
requiredboolean (optional)Die Einstellung „erforderlich“ des Felds.
optionsobject oder Array<{ value, label }> (optional)Die options des Felds aus dem Schema, unverändert durchgereicht. Verwende sie für feldspezifische Konfiguration wie max oben. Hat das Feld validation.options (Felder vom Typ select und multiSelect), übergibt der Host stattdessen diese, als Liste von { value, label }-Elementen.
validationobject (optional)Die validation-Regeln des Felds aus dem Schema.
minimalboolean (optional)true, wenn der Host eine kompakte Darstellung ohne umgebendes Label-Chrome anfordert.

Der Editor umschließt jedes Widget mit einer Error Boundary. Wirft die Komponente beim Rendern einen Fehler, zeigt das Feld eine Meldung „Plugin widget error“ mit einer Schaltfläche zum erneuten Versuch an, statt den Editor auszuhängen. Hat der Export fields unter dem angeforderten Namen keine Funktion, verwendet der Editor den Standard-Editor für den Feldtyp. Ein widget-Wert ohne : protokolliert eine Konsolenwarnung und fällt ebenfalls darauf zurück.

Content-Editor-Panels

Ein Editor-Panel fügt dem Einstellungs-Sidebar eines gespeicherten Eintrags einen vom Host gerahmten Abschnitt hinzu. Es wird nicht für einen neuen Eintrag gemountet, weil noch kein gespeicherter entry existiert.

Panels und Content-Listen-Spalten werden direkt aus dem vertrauenswürdigen Admin-Modul entdeckt. Sie brauchen adminEntry und admin.entry, damit das Modul geladen wird, aber keine Einträge in admin.pages oder admin.widgets.

import type {
	ContentEditorPanelContext,
	ContentEditorPanelExtension,
} from "@emdash-cms/admin";
import { useLingui } from "@lingui/react";

function ActivityPanel({ entry, collection, locale }: ContentEditorPanelContext) {
	const { i18n } = useLingui();
	const displayLocale =
		locale ??
		i18n._({ id: "activity.default-locale", message: "Default locale" });

	return (
		<p className="text-sm text-kumo-subtle">
			{collection}/{entry.slug} ({displayLocale})
		</p>
	);
}

export const contentEditorPanels = [
	{
		id: "activity-summary",
		title: "Activity summary",
		component: ActivityPanel,
		collections: ["posts", "pages"],
		order: 10,
	},
] satisfies readonly ContentEditorPanelExtension[];

Panel-Felder haben folgendes Verhalten:

  • id, title und component sind erforderlich. Die ID muss unter den Panels dieses Plugins eindeutig sein.
  • collections ist ein Array von Collection-Namen oder ein Prädikat. Lasse es weg, um das Panel für jede Collection anzuzeigen.
  • minRole ist ein numerischer Sichtbarkeitsschwellenwert. Es autorisiert keine API-Aufrufe.
  • order sortiert niedrigere Werte zuerst. Bei Gleichstand werden Plugin-ID und Panel-ID verwendet.

Die Komponente erhält den gespeicherten entry, seine collection und die aufgelöste locale. Halte das Layout responsiv für die schmale Sidebar. EmDash isoliert Komponenten- und Collection-Prädikat-Fehler, sodass ein Panel den Editor nicht aushängen kann.

Content-Listen-Spalten

Eine Content-Listen-Spalte fügt schreibgeschützte Zellen zu aktiven Collection-Listen hinzu. Der Host besitzt weiterhin Pagination, Zeilenaktionen, Lade- und Leerzustände sowie die Tabelle selbst. Spalten werden im Papierkorb nicht angezeigt.

Die folgende Spalte verwendet visibleItems, um eine Seite von Statuswerten abzurufen. Jede Zelle verwendet denselben React-Query-Key, sodass die Anfragen ein Ergebnis teilen, statt eine Anfrage pro Zeile auszugeben.

import { useQuery } from "@tanstack/react-query";
import type {
	ContentListColumnCellContext,
	ContentListColumnExtension,
} from "@emdash-cms/admin";
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";

async function loadStatuses(
	collection: string,
	locale: string | undefined,
	ids: readonly string[],
) {
	const response = await apiFetch(
		"/_emdash/api/plugins/plugin-activity/statuses",
		{
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ collection, locale, ids }),
		},
	);
	return parseApiResponse<Record<string, string>>(
		response,
		"Could not load activity statuses",
	);
}

function ActivityCell({
	item,
	visibleItems,
	collection,
	locale,
}: ContentListColumnCellContext) {
	const ids = visibleItems.map((visibleItem) => visibleItem.id);
	const { data } = useQuery({
		queryKey: ["plugin-activity", "statuses", collection, locale ?? null, ids],
		queryFn: () => loadStatuses(collection, locale, ids),
	});

	return <span>{data?.[item.id] ?? "-"}</span>;
}

export const contentListColumns = [
	{
		id: "activity",
		label: "Activity",
		cell: ActivityCell,
		collections: ["posts", "pages"],
		align: "end",
		order: 10,
	},
] satisfies readonly ContentListColumnExtension[];

Spaltenfelder haben folgendes Verhalten:

  • id, label und cell sind erforderlich. Die ID muss unter den Spalten dieses Plugins eindeutig sein.
  • header ersetzt den Header-Inhalt durch eine Komponente; label bleibt der Host-Fallback.
  • collections, minRole und order verhalten sich wie ihre Panel-Äquivalente.
  • align akzeptiert start oder end und verwendet logische Ausrichtung für Links-nach-Rechts- und Rechts-nach-Links-Locales.

Spalten können kein browserseitiges Sortieren oder Filtern hinzufügen. Diese Steuerelemente würden nur die geladene Cursor-Seite betreffen, nicht die gesamte serverseitige Collection.

Deaktivierte Plugins

Wenn ein Administrator das Plugin deaktiviert, entfernt EmDash seine Seiten, Widgets, Panels und Spalten aus der Adminoberfläche. Seine privaten Routen geben Not Found zurück, und seine Hooks laufen nicht mehr. Das erneute Aktivieren des Plugins baut die Hook-Pipeline neu auf und macht seine vertrauenswürdigen Admin-Exporte wieder verfügbar.

Einstiegspunkt paketieren

Exportiere das Admin-Modul getrennt von der Server-Runtime, damit Astro es für den Browser mit den React-, Kumo- und Lingui-Instanzen des Hosts bündeln kann. Native Plugins verteilen beschreibt das vollständige Package-Layout, Exporte, Peer-Dependencies und Build-Befehle.