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 :

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 } automatique
  • save(nextConfig) : patche settings.pluginConfigs[<id>] en base

Composant minimaliste

TSX
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 :

  1. useState<TConfig>(config) initialise le draft depuis la config courante
  2. useEffect(() => setDraft(config), [config]) re-hydrate si la config externe change (un autre admin sauvegarde dans un autre onglet)
  3. Les champs modifient draft localement, sans persister
  4. handleSave appelle save(draft) qui persiste

Pattern : sections + onglets

Pour des configs complexes, structurez en sections via card ou un strip d'onglets :

TSX
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 :

  1. Sauvegarde le draft d'abord (sinon le pass utiliserait la config pré-edit)
  2. Lance le pass
  3. Affiche les logs
TSX
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églages
  • description — affiché en haut de la page
  • sections.<name> — labels des sections
  • <field>.label — label de chaque champ
  • <field>.help — texte d'aide sous le champ
  • actions.save / actions.saving / actions.saved — bouton de save
  • actions.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 :

  1. Mettre data-form-type="other" sur le <form> (décourage l'autofill agressif)
  2. Pour les boutons avec icône qui swap (Save / Saving), utiliser un wrapper <span> stable avec className-toggled icons, jamais {saving ? <Loader /> : <Save />}
TSX
<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.