Page de réglages plugin
Les plugins peuvent livrer une page de configuration accessible à . La page vit à l'intérieur du layout standard de Réglages — même chrome que Général + Performance — pour que les admins aient une
Les plugins peuvent livrer une page de configuration accessible à /settings/plugin/<id>. La page vit à l'intérieur du layout standard de Réglages — même chrome que Général + Performance — pour que les admins aient une surface cohérente.
Cette page documente le côté author. Pour l'UX côté utilisateur, voir Réglages → Réglages des plugins.
Déclarer une page de réglages
Dans manifest.ts :
import { MyPluginSettingsPage, DEFAULT_CONFIG } from "./SettingsPage";
import type { MyPluginConfig } from "./SettingsPage";
export const manifest: PluginManifest<MyPluginConfig> = {
// …
settings: {
navLabelKey: "title", // clé i18n pour le label d'onglet
defaultConfig: DEFAULT_CONFIG, // valeurs par défaut
component: MyPluginSettingsPage, // composant React
},
};
Le composant reçoit { config, save } :
config: merge{ ...defaultConfig, ...stored }automatiquesave(nextConfig): patchesettings.pluginConfigs[<id>]en base
Composant minimaliste
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import type { PluginSettingsPageProps } from "@flexweg/cms-runtime";
export interface MyPluginConfig {
enabled: boolean;
webhookUrl: string;
}
export const DEFAULT_CONFIG: MyPluginConfig = {
enabled: false,
webhookUrl: "",
};
export function MyPluginSettingsPage({ config, save }: PluginSettingsPageProps<MyPluginConfig>) {
const { t } = useTranslation("my-plugin");
const [draft, setDraft] = useState<MyPluginConfig>(config);
useEffect(() => setDraft(config), [config]);
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
await save(draft);
} finally {
setSaving(false);
}
}
return (
<div className="space-y-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
<span>{t("enabled")}</span>
</label>
{draft.enabled && (
<div>
<label className="label">{t("webhookUrl")}</label>
<input
type="url"
className="input"
value={draft.webhookUrl}
onChange={(e) => setDraft({ ...draft, webhookUrl: e.target.value })}
/>
</div>
)}
<button className="btn-primary" onClick={handleSave} disabled={saving}>
{saving ? t("saving") : t("save")}
</button>
</div>
);
}
Pattern : draft + save
L'utilisateur peut éditer plusieurs champs avant de cliquer Save. Le pattern :
useState<TConfig>(config)initialise le draft depuis la config couranteuseEffect(() => setDraft(config), [config])re-hydrate si la config externe change (un autre admin sauvegarde dans un autre onglet)- Les champs modifient
draftlocalement, sans persister handleSaveappellesave(draft)qui persiste
Pattern : sections + onglets
Pour des configs complexes, structurez en sections via card ou un strip d'onglets :
const [tab, setTab] = useState<"general" | "advanced">("general");
return (
<div>
<div className="tab-strip">
<button onClick={() => setTab("general")}>{t("tabs.general")}</button>
<button onClick={() => setTab("advanced")}>{t("tabs.advanced")}</button>
</div>
{tab === "general" && <GeneralSection draft={draft} setDraft={setDraft} />}
{tab === "advanced" && <AdvancedSection draft={draft} setDraft={setDraft} />}
</div>
);
Pattern : Force regenerate
Si votre plugin a une logique de pass de régénération (sitemaps, RSS, archives), ajoutez un bouton qui :
- Sauvegarde le draft d'abord (sinon le pass utiliserait la config pré-edit)
- Lance le pass
- Affiche les logs
async function handleForceRegenerate() {
setRegenerating(true);
try {
await save(draft);
const result = await regenerateAll({ posts, terms, media, settings, config: draft });
toast.success(t("regenerated", { count: result.uploaded.length }));
} finally {
setRegenerating(false);
}
}
Réagir aux changements externes
Si vous avez besoin de réagir quand quelqu'un (vous ou un autre admin) sauvegarde dans un autre onglet, le useEffect ci-dessus suffit — config est live via la souscription useCmsData, donc il bouge automatiquement.
i18n
Convention forte : tous les labels utilisateur passent par t(). Bundlez les langues dans manifest.i18n. Voir Référence du manifest.
Clés courantes :
title— affiché comme label de l'onglet dans Réglagesdescription— affiché en haut de la pagesections.<name>— labels des sections<field>.label— label de chaque champ<field>.help— texte d'aide sous le champactions.save/actions.saving/actions.saved— bouton de saveactions.regenerate/actions.regenerating/actions.regenerated— bouton Force regenerate
Stockage
settings/site.pluginConfigs[<id>] est l'emplacement persisté. Vous n'écrivez jamais directement — toujours via save(nextConfig) qui appelle updatePluginConfig(id, config) (un dispatcher routé vers Firestore ou SQLite).
Anti-pattern : forms qui crashent
Les extensions navigateur (1Password, Bitwarden, Grammarly) injectent du DOM dans les formulaires et peuvent crasher React avec Node.insertBefore: Child to insert before is not a child of this node. Préventions :
- Mettre
data-form-type="other"sur le<form>(décourage l'autofill agressif) - Pour les boutons avec icône qui swap (Save / Saving), utiliser un wrapper
<span>stable avecclassName-toggled icons, jamais{saving ? <Loader /> : <Save />}
<button className="btn-primary" onClick={handleSave} disabled={saving}>
<span className="inline-flex items-center gap-2">
<Loader2 className={saving ? "h-4 w-4 animate-spin" : "hidden"} />
<Save className={saving ? "hidden" : "h-4 w-4"} />
<span>{saving ? t("saving") : t("save")}</span>
</span>
</button>
C'est le pattern utilisé partout dans l'admin et les plugins intégrés.