Extensions d'administration React

Sur cette page

Les plugins natifs peuvent charger des composants React de confiance dans l’administration EmDash. L’hôte conserve le contrôle de la navigation, du routage des pages, des cartes du tableau de bord, de la mise en page de l’éditeur, des tableaux de contenu, de l’authentification et des limites d’erreur. Le plugin fournit les composants et les métadonnées nécessaires pour les placer.

Si le plugin n’a besoin que d’un formulaire de paramètres, commencez par admin.settingsSchema. Il utilise les composants de formulaire de l’hôte et ne nécessite pas de point d’entrée React. Les plugins en bac à sable peuvent aussi utiliser ce formulaire généré ; les extensions React personnalisées de cette page nécessitent un plugin natif.

Formulaire de paramètres généré

Déclarez settingsSchema dans la définition d’exécution. Le schéma suivant produit un champ texte multiligne, un select, une saisie numérique, un interrupteur et une saisie de secret en écriture seule :

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",
			},
		},
	},
});

Les champs disponibles ont les options suivantes. label est obligatoire et description est optionnelle pour chaque type.

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

Le formulaire est accessible depuis le contrôle des paramètres de la carte du plugin dans Plugins. Le lire ou le modifier nécessite plugins:manage.

Les paramètres utilisent le magasin de paramètres espace de noms du plugin. Un champ nommé retentionDays est disponible pour le plugin sous retentionDays :

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

Les valeurs par défaut du schéma remplissent le formulaire généré lorsqu’aucune valeur n’est stockée, mais EmDash n’écrit pas ces valeurs par défaut dans le magasin de paramètres. Appliquez le même repli lorsque l’exécution lit le paramètre. Effacer un champ non secret supprime sa valeur stockée et ramène le formulaire à sa valeur par défaut. Les valeurs secrètes sont chiffrées avant la persistance et ne sont jamais renvoyées au navigateur ; le formulaire indique seulement si un secret est défini et permet à un administrateur de le remplacer ou de l’effacer.

Les libellés et descriptions des paramètres sont rendus tels que déclarés. Si ces chaînes doivent changer avec la locale d’administration, construisez plutôt une page de paramètres React personnalisée.

Point d’entrée React

Une extension React de confiance a trois déclarations liées :

  1. Le adminEntry du descripteur indique à Astro quel module empaqueter dans l’administration.
  2. Les admin.entry, admin.pages et admin.widgets de l’exécution décrivent les surfaces d’administration visibles.
  3. Le module d’administration exporte des maps de composants dont les clés correspondent aux chemins de page et aux ID de widget déclarés.

Le descripteur n’a besoin que du spécificateur de module. Les métadonnées de page et de widget appartiennent à la définition d’exécution :

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" }],
		},
	});
}

Gardez adminEntry et admin.entry identiques. Le premier est une importation au moment de la compilation ; le second indique à l’exécution que le plugin utilise des composants React d’administration de confiance.

Pages d’administration

Chaque déclaration de page a les champs suivants :

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.

Le module d’administration mappe chaque chemin déclaré à un composant React. Une barre oblique finale est traitée comme équivalente, et la racine du plugin ouvre la première page exportée lorsqu’aucune page / n’existe.

La page suivante charge une route privée du plugin. Utilisez Kumo pour les contrôles et apiFetch() pour les requêtes API du plugin ; apiFetch() ajoute l’en-tête X-EmDash-Request: 1 requis par les routes privées authentifiées par cookie.

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>
	);
}

Définissez la route correspondante dans l’exécution native. Les handlers natifs reçoivent un argument de contexte :

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

Les routes privées utilisent par défaut la permission réservée aux administrateurs plugins:manage. Déclarez la permission existante la plus étroite qui correspond à l’opération. Utilisez public: true uniquement pour un endpoint destiné au trafic Internet non authentifié.

Exportez la page depuis le point d’entrée d’administration :

import type { PluginAdminExports } from "emdash";

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

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

Les libellés de page passent par l’instance Lingui partagée de l’administration. Un libellé comme Settings utilise la traduction de l’administration lorsqu’elle existe. Un plugin peut charger son propre catalogue de messages dans l’instance partagée pour les libellés et messages de composants spécifiques au plugin ; sinon le message anglais déclaré est le repli.

Chargez le catalogue du plugin lorsque le point d’entrée d’administration est importé, puis rechargez-le après que l’administrateur a changé de locale. Le petit catalogue allemand suivant utilise les mêmes ID que les exemples de page et de widget :

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);

Importez le chargeur pour son effet de bord d’enregistrement avant d’exporter les composants :

import "./i18n.js";

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

L’administration remplace son catalogue actif lorsque la locale change. L’écouteur change restaure les messages du plugin, et la vérification de l’ID de message empêche i18n.load() de déclencher une boucle. Pour plus de locales, générez les objets de message avec la compilation Lingui du plugin au lieu de les maintenir à la main. Gardez @lingui/core et @lingui/react comme peer dependencies afin que le plugin utilise l’instance partagée de l’hôte.

Widgets du tableau de bord

Une déclaration de widget a un id obligatoire et des title et size optionnels :

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

Exportez un composant sous le même ID. Ce widget lit la même route de résumé que la page et ne fournit que le contenu de la carte ; EmDash fournit la carte du tableau de bord environnante et le titre.

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,
};

L’hôte place le composant dans une carte du tableau de bord et rend title comme titre. Gardez le composant compact et n’ajoutez pas une seconde enveloppe de carte. size accepte full, half ou third ; il est stocké comme indication de mise en page, mais le tableau de bord actuel rend les widgets du plugin dans sa grille responsive à deux colonnes sans appliquer cette indication.

Panneaux de l’éditeur de contenu

Un panneau d’éditeur ajoute une section encadrée par l’hôte à la barre latérale des paramètres d’une entrée enregistrée. Il n’est pas monté pour une nouvelle entrée car aucun entry enregistré n’existe encore.

Les panneaux et les colonnes de liste de contenu sont découverts directement depuis le module d’administration de confiance. Ils ont besoin de adminEntry et admin.entry pour que le module se charge, mais pas d’entrées dans admin.pages ou 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[];

Les champs du panneau ont le comportement suivant :

  • id, title et component sont obligatoires. L’ID doit être unique parmi les panneaux de ce plugin.
  • collections est un tableau de noms de collection ou un prédicat. Omettez-le pour afficher le panneau pour chaque collection.
  • minRole est un seuil numérique de visibilité. Il n’autorise pas les appels API.
  • order trie les valeurs plus bas en premier. Les égalités utilisent l’ID du plugin et l’ID du panneau.

Le composant reçoit l’entry enregistré, sa collection et le locale résolu. Gardez sa mise en page responsive à la barre latérale étroite. EmDash isole les échecs de composant et de prédicat de collection afin qu’un panneau ne puisse pas démonter l’éditeur.

Colonnes de liste de contenu

Une colonne de liste de contenu ajoute des cellules en lecture seule aux listes de collection actives. L’hôte possède toujours la pagination, les actions de ligne, les états de chargement et vide, et le tableau lui-même. Les colonnes ne sont pas affichées dans la Corbeille.

La colonne suivante utilise visibleItems pour récupérer une page de statuts. Chaque cellule utilise la même clé React Query, de sorte que les requêtes partagent un résultat au lieu d’émettre une requête par ligne.

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[];

Les champs de colonne ont le comportement suivant :

  • id, label et cell sont obligatoires. L’ID doit être unique parmi les colonnes de ce plugin.
  • header remplace le contenu de l’en-tête par un composant ; label reste le repli de l’hôte.
  • collections, minRole et order se comportent comme leurs équivalents de panneau.
  • align accepte start ou end et utilise l’alignement logique pour les locales de gauche à droite et de droite à gauche.

Les colonnes ne peuvent pas ajouter de tri ou de filtrage uniquement navigateur. Ces contrôles n’affecteraient que la page de curseur chargée, pas toute la collection côté serveur.

Plugins désactivés

Lorsqu’un administrateur désactive le plugin, EmDash retire ses pages, widgets, panneaux et colonnes de l’administration. Ses routes privées renvoient introuvable, et ses hooks cessent de s’exécuter. La réactivation du plugin reconstruit le pipeline de hooks et rend à nouveau disponibles ses exportations d’administration de confiance.

Empaqueter le point d’entrée

Exportez le module d’administration séparément de l’exécution serveur afin qu’Astro puisse l’empaqueter pour le navigateur avec les instances React, Kumo et Lingui de l’hôte. Distribuer des plugins natifs fournit la disposition complète du package, les exportations, les peer dependencies et les commandes de compilation.