Extensiones de administración React

En esta página

Los plugins nativos pueden cargar componentes React de confianza en la administración de EmDash. El host mantiene el control de la navegación, el enrutamiento de páginas, las tarjetas del panel, el diseño del editor, las tablas de contenido, la autenticación y los límites de error. El plugin suministra los componentes y los metadatos necesarios para colocarlos.

Si el plugin solo necesita un formulario de ajustes, empieza con admin.settingsSchema. Usa los componentes de formulario del host y no requiere un punto de entrada React. Los plugins en sandbox también pueden usar este formulario generado; las extensiones React personalizadas de esta página requieren un plugin nativo.

Formulario de ajustes generado

Declara settingsSchema dentro de la definición de runtime. El siguiente esquema produce un campo de texto multilínea, un select, una entrada numérica, un interruptor y una entrada de secreto solo de escritura:

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

Los campos disponibles tienen las siguientes opciones. label es obligatorio y description es opcional para cada tipo.

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

El formulario está disponible desde el control de ajustes de la tarjeta del plugin en Plugins. Leer o cambiarlo requiere plugins:manage.

Los ajustes usan el almacén de ajustes con espacio de nombres del plugin. Un campo llamado retentionDays está disponible para el plugin como retentionDays:

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

Los valores predeterminados del esquema rellenan el formulario generado cuando no hay valor almacenado, pero EmDash no escribe esos valores predeterminados en el almacén de ajustes. Aplica el mismo respaldo cuando el runtime lee el ajuste. Vaciar un campo que no es secreto elimina su valor almacenado y devuelve el formulario a su valor predeterminado. Los valores secretos se cifran antes de persistir y nunca se envían de vuelta al navegador; el formulario solo informa si un secreto está establecido y permite a un administrador reemplazarlo o borrarlo.

Las etiquetas y descripciones de los ajustes se renderizan tal como se declaran. Si esas cadenas deben cambiar con la configuración regional de la administración, construye en su lugar una página de ajustes React personalizada.

Punto de entrada React

Una extensión React de confianza tiene tres declaraciones conectadas:

  1. El adminEntry del descriptor indica a Astro qué módulo empaquetar en la administración.
  2. El admin.entry, admin.pages y admin.widgets del runtime describen las superficies de administración visibles.
  3. El módulo de administración exporta mapas de componentes cuyas claves coinciden con las rutas de página y los IDs de widget declarados.

El descriptor solo necesita el especificador del módulo. Los metadatos de página y widget pertenecen a la definición de runtime:

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

Mantén adminEntry y admin.entry idénticos. El primero es una importación en tiempo de compilación; el segundo indica al runtime que el plugin usa componentes React de administración de confianza.

Páginas de administración

Cada declaración de página tiene los siguientes campos:

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.

El módulo de administración asigna cada ruta declarada a un componente React. Una barra diagonal final se trata como equivalente, y la raíz del plugin abre la primera página exportada cuando no existe una página /.

La siguiente página carga una ruta privada del plugin. Usa Kumo para los controles y apiFetch() para las solicitudes a la API del plugin; apiFetch() añade el encabezado X-EmDash-Request: 1 requerido por las rutas privadas autenticadas con cookies.

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

Define la ruta correspondiente en el runtime nativo. Los handlers nativos reciben un argumento de contexto:

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

Las rutas privadas usan por defecto el permiso solo para administradores plugins:manage. Declara el permiso existente más estrecho que coincida con la operación. Usa public: true solo para un endpoint destinado al tráfico de Internet no autenticado.

Exporta la página desde el punto de entrada de administración:

import type { PluginAdminExports } from "emdash";

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

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

Las etiquetas de página pasan por la instancia Lingui compartida de la administración. Una etiqueta como Settings usa la traducción de la administración cuando existe. Un plugin puede cargar su propio catálogo de mensajes en la instancia compartida para etiquetas y mensajes de componentes específicos del plugin; de lo contrario, el mensaje en inglés declarado es el respaldo.

Carga el catálogo del plugin cuando se importa el punto de entrada de administración, y cárgalo de nuevo después de que el administrador cambie la configuración regional. El siguiente catálogo alemán pequeño usa los mismos IDs que los ejemplos de página y 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);

Importa el cargador por su efecto secundario de registro antes de exportar componentes:

import "./i18n.js";

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

La administración reemplaza su catálogo activo cuando cambia la configuración regional. El listener change restaura los mensajes del plugin, y la comprobación del ID de mensaje evita que i18n.load() dispare un bucle. Para más configuraciones regionales, genera los objetos de mensaje con la compilación Lingui del plugin en lugar de mantenerlos a mano. Mantén @lingui/core y @lingui/react como peer dependencies para que el plugin use la instancia compartida del host.

Widgets del panel

Una declaración de widget tiene un id obligatorio y title y size opcionales:

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

Exporta un componente bajo el mismo ID. Este widget lee la misma ruta de resumen que la página y suministra solo el contenido de la tarjeta; EmDash suministra la tarjeta del panel circundante y el encabezado.

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

El host coloca el componente dentro de una tarjeta del panel y renderiza title como su encabezado. Mantén el componente compacto y no añadas una segunda envoltorio de tarjeta. size acepta full, half o third; se almacena como pista de diseño, pero el panel actual renderiza los widgets del plugin en su cuadrícula responsiva de dos columnas sin aplicar esa pista.

Paneles del editor de contenido

Un panel del editor añade una sección enmarcada por el host a la barra lateral de ajustes de una entrada guardada. No se monta para una entrada nueva porque aún no existe un entry guardado.

Los paneles y las columnas de lista de contenido se descubren directamente desde el módulo de administración de confianza. Necesitan adminEntry y admin.entry para que el módulo se cargue, pero no necesitan entradas en admin.pages o 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[];

Los campos del panel tienen el siguiente comportamiento:

  • id, title y component son obligatorios. El ID debe ser único entre los paneles de este plugin.
  • collections es un array de nombres de colección o un predicado. Omítelo para mostrar el panel en todas las colecciones.
  • minRole es un umbral numérico de visibilidad. No autoriza llamadas a la API.
  • order ordena primero los valores más bajos. Los empates usan el ID del plugin y el ID del panel.

El componente recibe el entry guardado, su collection y el locale resuelto. Mantén su diseño responsivo a la barra lateral estrecha. EmDash aísla los fallos del componente y del predicado de colección para que un panel no pueda desmontar el editor.

Columnas de lista de contenido

Una columna de lista de contenido añade celdas de solo lectura a las listas de colección activas. El host sigue siendo dueño de la paginación, las acciones de fila, los estados de carga y vacío, y la tabla en sí. Las columnas no se muestran en la Papelera.

La siguiente columna usa visibleItems para obtener una página de estados. Cada celda usa la misma clave de React Query, de modo que las solicitudes comparten un resultado en lugar de emitir una solicitud por fila.

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

Los campos de columna tienen el siguiente comportamiento:

  • id, label y cell son obligatorios. El ID debe ser único entre las columnas de este plugin.
  • header reemplaza el contenido del encabezado con un componente; label sigue siendo el respaldo del host.
  • collections, minRole y order se comportan como sus equivalentes de panel.
  • align acepta start o end y usa alineación lógica para configuraciones regionales de izquierda a derecha y de derecha a izquierda.

Las columnas no pueden añadir ordenación o filtrado solo del navegador. Esos controles afectarían solo a la página de cursor cargada, no a toda la colección respaldada por el servidor.

Plugins deshabilitados

Cuando un administrador deshabilita el plugin, EmDash elimina sus páginas, widgets, paneles y columnas de la administración. Sus rutas privadas devuelven no encontrado, y sus hooks dejan de ejecutarse. Volver a habilitar el plugin reconstruye la canalización de hooks y hace que sus exportaciones de administración de confianza vuelvan a estar disponibles.

Empaquetar el punto de entrada

Exporta el módulo de administración por separado del runtime del servidor para que Astro pueda empaquetarlo para el navegador con las instancias React, Kumo y Lingui del host. Distribuir plugins nativos proporciona el diseño completo del paquete, las exportaciones, las peer dependencies y los comandos de compilación.