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.

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

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.

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:

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.

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.

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.