Extensões de administração React

Nesta página

Plugins nativos podem carregar componentes React confiáveis na administração do EmDash. O host mantém o controle da navegação, roteamento de páginas, cartões do painel, layout do editor, tabelas de conteúdo, autenticação e limites de erro. O plugin fornece os componentes e os metadados necessários para posicioná-los.

Se o plugin só precisa de um formulário de configurações, comece com admin.settingsSchema. Ele usa os componentes de formulário do host e não exige um ponto de entrada React. Plugins em sandbox também podem usar este formulário gerado; as extensões React personalizadas desta página exigem um plugin nativo.

Formulário de configurações gerado

Declare settingsSchema na definição de runtime. O seguinte esquema produz um campo de texto multilinha, um select, uma entrada numérica, um interruptor e uma entrada de segredo somente gravação:

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

Os campos disponíveis têm as seguintes opções. label é obrigatório e description é opcional para cada tipo.

typeValorCampos adicionais
stringstringdefault, multiline
numbernumberdefault, min, max
booleanbooleandefault
selectstringoptions: Array<{ value, label }> obrigatório e default opcional
secretstringsem campos adicionais; o valor armazenado nunca é retornado ao navegador
urlstringdefault, placeholder
emailstringdefault, placeholder

O formulário está disponível no controle de configurações do cartão do plugin em Plugins. Ler ou alterá-lo exige plugins:manage.

As configurações usam o armazenamento de configurações com namespace do plugin. Um campo chamado retentionDays está disponível para o plugin como retentionDays:

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

Os padrões do esquema preenchem o formulário gerado quando nenhum valor está armazenado, mas o EmDash não grava esses padrões no armazenamento de configurações. Aplique o mesmo fallback quando o runtime ler a configuração. Limpar um campo que não é segredo exclui seu valor armazenado e devolve o formulário ao padrão. Valores secretos são criptografados antes da persistência e nunca são enviados de volta ao navegador; o formulário informa apenas se um segredo está definido e permite que um administrador o substitua ou limpe.

Rótulos e descrições de configurações são renderizados conforme declarados. Se essas strings precisarem mudar com a locale da administração, construa em vez disso uma página de configurações React personalizada.

Ponto de entrada React

Uma extensão React confiável tem três declarações conectadas:

  1. O adminEntry do descritor diz ao Astro qual módulo empacotar na administração.
  2. Os admin.entry, admin.pages e admin.widgets do runtime descrevem as superfícies de administração visíveis.
  3. O módulo de administração exporta mapas de componentes cujas chaves correspondem aos caminhos de página e IDs de widget declarados. Os widgets de campo são a exceção: eles são resolvidos pelo nome a partir da exportação fields e não precisam de declaração.

O descritor só precisa do especificador do módulo. Metadados de página e widget pertencem à definição 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" }],
		},
	});
}

Mantenha adminEntry e admin.entry idênticos. O primeiro é uma importação em tempo de build; o segundo diz ao runtime que o plugin usa componentes React de administração confiáveis.

Páginas de administração

Cada declaração de página tem os seguintes campos:

CampoObrigatórioComportamento
pathSimMonta a página em /_emdash/admin/plugins/<plugin-id><path>. Use uma barra inicial.
labelSimFornece o rótulo da barra lateral e da paleta de comandos.
iconNãoNomeia um ícone do Phosphor em formato kebab, snake, separado por espaços ou PascalCase. Nomes desconhecidos recorrem ao ícone do plugin.
groupNãoColoca a página em uma pasta recolhível da barra lateral. Um grupo que corresponda ao grupo de uma coleção exibida na barra lateral (diferencia maiúsculas de minúsculas e ignora espaços ao redor) coloca a página nessa pasta, depois de suas coleções e taxonomias; caso contrário, as páginas que compartilham o grupo, de qualquer plugin, formam uma pasta na seção Plugins.

O módulo de administração mapeia cada caminho declarado para um componente React. Uma barra final é tratada como equivalente, e a raiz do plugin abre a primeira página exportada quando não existe uma página /.

A página a seguir carrega uma rota privada do plugin. Use Kumo para controles e apiFetch() para solicitações à API do plugin; apiFetch() adiciona o cabeçalho X-EmDash-Request: 1 exigido por rotas privadas autenticadas por 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>
	);
}

Defina a rota correspondente no runtime nativo. Handlers nativos recebem um argumento de contexto:

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

Rotas privadas usam por padrão a permissão somente administrador plugins:manage. Declare a permissão existente mais estreita que corresponda à operação. Use public: true apenas para um endpoint destinado a tráfego de Internet não autenticado.

Exporte a página do ponto de entrada de administração:

import type { PluginAdminExports } from "emdash";

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

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

Rótulos de página passam pela instância Lingui compartilhada da administração. Um rótulo como Settings usa a tradução da administração quando existe. Um plugin pode carregar seu próprio catálogo de mensagens na instância compartilhada para rótulos e mensagens de componentes específicos do plugin; caso contrário, a mensagem em inglês declarada é o fallback.

Carregue o catálogo do plugin quando o ponto de entrada de administração for importado, e carregue-o novamente depois que o administrador mudar a locale. O pequeno catálogo alemão a seguir usa os mesmos IDs dos exemplos de página e 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);

Importe o carregador pelo efeito colateral de registro antes de exportar componentes:

import "./i18n.js";

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

A administração substitui seu catálogo ativo quando a locale muda. O listener change restaura as mensagens do plugin, e a verificação do ID da mensagem impede que i18n.load() dispare um loop. Para mais locales, gere os objetos de mensagem com o build Lingui do plugin em vez de mantê-los à mão. Mantenha @lingui/core e @lingui/react como peer dependencies para que o plugin use a instância compartilhada do host.

Widgets do painel

Uma declaração de widget tem um id obrigatório e title e size opcionais:

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

Exporte um componente sob o mesmo ID. Este widget lê a mesma rota de resumo da página e fornece apenas o conteúdo do cartão; o EmDash fornece o cartão do painel ao redor e o título.

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

O host coloca o componente dentro de um cartão do painel e renderiza title como título. Mantenha o componente compacto e não adicione um segundo invólucro de cartão. size aceita full, half ou third; é armazenado como dica de layout, mas o painel atual renderiza widgets do plugin em sua grade responsiva de duas colunas sem aplicar essa dica.

Widgets de campo personalizados

Um widget de campo substitui o editor de um campo do esquema por um componente do seu plugin. Exporte um mapa fields a partir do módulo de administração, indexado pelo nome do widget:

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

O plugin precisa do par adminEntry e admin.entry descrito em Ponto de entrada React para que o módulo seja carregado. Nada mais no descritor ou na chamada a definePlugin() faz referência a um widget de campo.

Um campo do esquema ativa o widget definindo widget como <plugin-id>:<widget-name>. O ID do plugin é o id passado a definePlugin(), e o nome do widget é uma chave da exportação fields. O campo de seed a seguir usa o widget acima em um plugin cujo ID é plugin-reviews:

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

A API de esquema também aceita widget. Escolha um type de campo que possa conter o valor produzido pelo widget; o widget altera apenas o editor, não a forma como o valor é armazenado.

O componente recebe as seguintes props:

PropTipoComportamento
valueunknownO valor atual do campo no editor. Restrinja o tipo antes de usá-lo.
onChange(value: unknown) => voidChame com o novo valor, não com um evento.
labelstringO rótulo do campo. O host não renderiza um em volta de um widget de plugin, então renderize você mesmo.
idstringUm ID do DOM para o campo, no formato field-<slug>.
requiredboolean (opcional)A configuração de obrigatoriedade do campo.
optionsobject ou Array<{ value, label }> (opcional)As options do campo no esquema, repassadas sem alteração. Use-as para configuração por campo, como max acima. Quando o campo tem validation.options (campos select e multiSelect), o host passa essas no lugar, como uma lista de itens { value, label }.
validationobject (opcional)As regras de validation do campo no esquema.
minimalboolean (opcional)true quando o host pede uma renderização compacta, sem o contorno do rótulo ao redor.

O editor envolve cada widget em um error boundary. Se o componente lançar um erro ao renderizar, o campo mostra uma mensagem “Plugin widget error” com um botão de nova tentativa, em vez de desmontar o editor. Se a exportação fields não tiver nenhuma função com o nome solicitado, o editor usa o editor padrão do tipo de campo. Um valor de widget sem : registra um aviso no console e também recorre ao editor padrão.

Painéis do editor de conteúdo

Um painel do editor adiciona uma seção emoldurada pelo host à barra lateral de configurações de uma entrada salva. Não é montado para uma nova entrada porque ainda não existe um entry salvo.

Painéis e colunas de lista de conteúdo são descobertos diretamente do módulo de administração confiável. Precisam de adminEntry e admin.entry para o módulo carregar, mas não precisam de entradas em 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[];

Os campos do painel têm o seguinte comportamento:

  • id, title e component são obrigatórios. O ID deve ser exclusivo entre os painéis deste plugin.
  • collections é um array de nomes de coleção ou um predicado. Omita-o para mostrar o painel em todas as coleções.
  • minRole é um limiar numérico de visibilidade. Não autoriza chamadas de API.
  • order ordena valores mais baixos primeiro. Empates usam o ID do plugin e o ID do painel.

O componente recebe o entry salvo, sua collection e o locale resolvido. Mantenha o layout responsivo à barra lateral estreita. O EmDash isola falhas de componente e de predicado de coleção para que um painel não possa desmontar o editor.

Colunas de lista de conteúdo

Uma coluna de lista de conteúdo adiciona células somente leitura às listas de coleção ativas. O host ainda é dono da paginação, ações de linha, estados de carregamento e vazio, e da própria tabela. Colunas não são mostradas na Lixeira.

A coluna a seguir usa visibleItems para buscar uma página de status. Cada célula usa a mesma chave React Query, então as solicitações compartilham um resultado em vez de emitir uma solicitação por linha.

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

Os campos de coluna têm o seguinte comportamento:

  • id, label e cell são obrigatórios. O ID deve ser exclusivo entre as colunas deste plugin.
  • header substitui o conteúdo do cabeçalho por um componente; label permanece o fallback do host.
  • collections, minRole e order comportam-se como seus equivalentes de painel.
  • align aceita start ou end e usa alinhamento lógico para locales da esquerda para a direita e da direita para a esquerda.

Colunas não podem adicionar ordenação ou filtragem só no navegador. Esses controles afetariam apenas a página de cursor carregada, não toda a coleção no servidor.

Plugins desabilitados

Quando um administrador desabilita o plugin, o EmDash remove suas páginas, widgets, painéis e colunas da administração. Suas rotas privadas retornam não encontrado, e seus hooks param de executar. Reabilitar o plugin reconstrói o pipeline de hooks e torna suas exportações de administração confiáveis novamente disponíveis.

Empacotar o ponto de entrada

Exporte o módulo de administração separadamente do runtime do servidor para que o Astro possa empacotá-lo para o navegador com as instâncias React, Kumo e Lingui do host. Distribuir plugins nativos fornece o layout completo do pacote, exportações, peer dependencies e comandos de build.