Páginas de administración y widgets React

En esta página

Los plugins nativos pueden extender el panel de administración con páginas React personalizadas, widgets de dashboard, widgets de campo y columnas de lista de contenido — los plugins sandboxed describen su UI como Block Kit en su lugar, porque enviar JavaScript de plugin al admin rompería el aislamiento del sandbox.

Si tu plugin solo necesita un formulario de configuración, el formulario auto-generado admin.settingsSchema (ver Tu primer plugin nativo) cubre la mayoría de los casos sin escribir React. Recurre a componentes personalizados cuando necesites una UI más rica de lo que settingsSchema proporciona.

Punto de entrada admin

Los plugins con UI de admin exportan objetos pages y widgets desde un punto de entrada admin:

import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";

export const widgets = {
	"seo-overview": SEODashboardWidget,
};

export const pages = {
	"/settings": SEOSettingsPage,
};

Configura el punto de entrada en package.json:

{
	"exports": {
		".": "./dist/index.js",
		"./admin": "./dist/admin.js"
	}
}

Referéncialo desde definePlugin():

definePlugin({
	id: "seo",
	version: "1.0.0",
	admin: {
		entry: "@my-org/plugin-seo/admin",
		pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
		widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
	},
});

Páginas de admin

Las páginas de admin son componentes React que se montan bajo /_emdash/admin/plugins/<plugin-id>/<ruta>.

Definición de página

admin: {
	pages: [
		{ path: "/settings", label: "Settings", icon: "settings" },
		{ path: "/reports", label: "Reports", icon: "chart" },
	],
}

Declara labels en inglés. El admin los pasa por su instancia compartida de Lingui antes de renderizar la barra lateral y la paleta de comandos, por lo que un plugin que carga su propio catálogo de mensajes obtiene navegación localizada gratis.

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

const catalogs: Record<string, Record<string, string>> = {
	de: { Reports: "Berichte", Settings: "Einstellungen" },
};

function mergeCatalog() {
	const messages = catalogs[i18n.locale];
	if (messages && !("Reports" in i18n.messages)) i18n.load(i18n.locale, messages);
}

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

Componente de página

import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";

export function SettingsPage() {
	const api = usePluginAPI();
	const [settings, setSettings] = useState<Record<string, unknown>>({});
	const [saving, setSaving] = useState(false);

	useEffect(() => {
		api.get("settings").then(setSettings);
	}, []);

	const handleSave = async () => {
		setSaving(true);
		await api.post("settings/save", settings);
		setSaving(false);
	};

	return (
		<div>
			<h1>Configuración del plugin</h1>
			<label>
				Título del sitio
				<input
					type="text"
					value={(settings.siteTitle as string) || ""}
					onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
				/>
			</label>
			<button onClick={handleSave} disabled={saving}>
				{saving ? "Guardando..." : "Guardar configuración"}
			</button>
		</div>
	);
}

Hook de API del plugin

usePluginAPI() llama a las rutas de tu plugin con el prefijo del plugin id y el header CSRF X-EmDash-Request: 1 añadido automáticamente:

import { usePluginAPI } from "@emdash-cms/admin";

function MyComponent() {
	const api = usePluginAPI();
	const data = await api.get("status");
	await api.post("settings/save", { enabled: true });
}

Widgets del dashboard

Los widgets aparecen en el dashboard de admin y proporcionan información de un vistazo.

admin: {
	widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
}

Tamaños de widget

TamañoDescripción
fullAncho completo del dashboard
halfMedio ancho del dashboard
thirdUn tercio del ancho del dashboard

Paneles del editor de contenido

Un plugin React de confianza puede agregar secciones enmarcadas por el host a la barra lateral de configuración para entradas de contenido guardadas.

import type { ContentEditorPanelContext } from "@emdash-cms/admin";

function ContentInsights({ entry, locale }: ContentEditorPanelContext) {
	return <p>Análisis para {entry.slug} en {locale ?? "el locale predeterminado"}</p>;
}

export const contentEditorPanels = [
	{
		id: "content-insights",
		title: "Content insights",
		component: ContentInsights,
		collections: ["posts", "pages"],
		minRole: 40,
		order: 10,
	},
];

Columnas de lista de contenido

Los plugins React de confianza pueden agregar columnas de solo lectura a las listas de colecciones de contenido activas.

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

async function fetchReviewStatuses(
	collection: string,
	locale: string | undefined,
	ids: readonly string[],
) {
	const response = await apiFetch(
		"/_emdash/api/plugins/editorial-workflow/review-statuses/batch",
		{
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ collection, locale, ids }),
		},
	);
	return parseApiResponse<Record<string, string>>(response, "Failed to load review statuses");
}

function ReviewStatusCell({ item, visibleItems, collection, locale }: ContentListColumnCellContext) {
	const itemIds = visibleItems.map((visibleItem) => visibleItem.id);
	const { data: reviewStatuses } = useQuery({
		queryKey: ["editorial-workflow", "review-statuses", collection, locale ?? null, itemIds],
		queryFn: () => fetchReviewStatuses(collection, locale, itemIds),
	});
	const reviewStatus = reviewStatuses?.[item.id];
	return reviewStatus ? <span>{reviewStatus}</span> : <span>-</span>;
}

export const contentListColumns = [
	{
		id: "review-status",
		label: "Estado de revisión",
		collections: ["posts", "pages"],
		order: 10,
		align: "end",
		cell: ReviewStatusCell,
	},
] satisfies readonly ContentListColumnExtension[];

Estructura de exportación

import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";
import { ReviewStatusCell } from "./components/ReviewStatusCell";

export const pages = {
	"/settings": SettingsPage,
	"/reports": ReportsPage,
};

export const widgets = {
	status: StatusWidget,
	overview: OverviewWidget,
};

export const contentListColumns = [
	{ id: "review-status", label: "Estado de revisión", collections: ["posts"], cell: ReviewStatusCell },
];

Usar componentes admin

import { Card, Button, Input, Select, Toggle, Table, Pagination, Alert, Loading } from "@emdash-cms/admin";

function SettingsPage() {
	return (
		<Card title="Configuración">
			<Input label="Clave API" type="password" />
			<Toggle label="Habilitado" defaultChecked />
			<Button variant="primary">Guardar</Button>
		</Card>
	);
}

UI de configuración auto-generada

admin: {
	settingsSchema: {
		apiKey: { type: "secret", label: "API Key" },
		enabled: { type: "boolean", label: "Enabled", default: true },
	},
},

EmDash genera una página de configuración automáticamente. Recurre a páginas React personalizadas solo cuando necesites comportamiento más allá de un formulario básico.

Las páginas del plugin aparecen en la barra lateral del admin bajo el nombre del plugin.

Configuración de build

tsdown

export default {
	entry: { index: "src/index.ts", admin: "src/admin.tsx" },
	format: "esm",
	dts: true,
	external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],
};

tsup

export default {
	entry: ["src/index.ts", "src/admin.tsx"],
	format: "esm",
	dts: true,
	external: ["react", "react-dom", "emdash", "@emdash-cms/admin"],
};

Habilitar/deshabilitar plugin

Cuando un plugin se deshabilita en el admin:

  • Los enlaces de la barra lateral se ocultan.
  • Los widgets del dashboard no se renderizan.
  • Las columnas de lista de contenido no se renderizan.
  • Las páginas admin devuelven 404.
  • Los hooks del backend siguen ejecutándose (por seguridad de datos).
const enabled = await ctx.kv.get<boolean>("_emdash:enabled");

Ejemplo completo

import { definePlugin } from "emdash";

export function createPlugin() {
	return definePlugin({
		id: "analytics",
		version: "1.0.0",
		capabilities: ["network:request"],
		allowedHosts: ["api.analytics.example.com"],
		storage: { events: { indexes: ["type", "createdAt"] } },
		admin: {
			entry: "@my-org/plugin-analytics/admin",
			settingsSchema: {
				trackingId: { type: "string", label: "Tracking ID" },
				enabled: { type: "boolean", label: "Enabled", default: true },
			},
			pages: [
				{ path: "/dashboard", label: "Dashboard", icon: "chart" },
				{ path: "/settings", label: "Settings", icon: "settings" },
			],
			widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
		},
		routes: {
			stats: {
				handler: async (ctx) => {
					const today = new Date().toISOString().split("T")[0];
					const count = await ctx.storage.events.count({ createdAt: { gte: today } });
					return { today: count };
				},
			},
		},
	});
}

export default createPlugin;
import { EventsWidget } from "./components/EventsWidget";
import { DashboardPage } from "./components/DashboardPage";
import { SettingsPage } from "./components/SettingsPage";

export const widgets = { "events-today": EventsWidget };
export const pages = { "/dashboard": DashboardPage, "/settings": SettingsPage };