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.

typeValueAdditional fields
stringstringdefault, multiline
numbernumberdefault, min, max
booleanbooleandefault
selectstringrequired options: Array<{ value, label }> and optional default
secretstringno additional fields; the stored value is never returned to the browser
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.

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:

FieldRequiredBehavior
pathYesMounts the page at /_emdash/admin/plugins/<plugin-id><path>. Use a leading slash.
labelYesSupplies the sidebar and command-palette label.
iconNoNames a Phosphor icon in kebab, snake, space-separated, or PascalCase form. Unknown names fall back to the plugin icon.

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.

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.