Aller au contenu

Développer une extension

Une extension SolisForms est un plugin WordPress ordinaire qui s’accroche à solis_forms_register_addons. Elle reçoit le conteneur de services au démarrage, et accède ainsi aux registres du plugin sans qu’ils transitent par le hook.

Squelette

<?php
/**
 * Plugin Name: SolisForms — Mon extension
 */

use SolisForms\AddOns\AddOnInterface;
use SolisForms\Core\Container;

final class Mon_Extension implements AddOnInterface {

    public function get_id(): string {
        return 'mon-extension';
    }

    public function get_name(): string {
        return 'Mon extension';
    }

    public function boot( Container $container ): void {
        // Point d'entrée : brancher ici les hooks de l'extension.
    }
}

add_action( 'solis_forms_register_addons', function ( $manager ) {
    $manager->register( new Mon_Extension() );
} );

L’extension apparaît alors dans SolisForms → Extensions. Si son boot() lève une erreur, le plugin poursuit son chargement sans elle et l’écran la signale en échec : une extension défaillante ne met jamais le site à terre.

Chargez votre extension après SolisForms — un plugin ordinaire l’est déjà, puisque le hook n’est émis que sur init.

Ajouter un type de champ

Étendre AbstractField fournit le rendu accessible commun (libellé associé, aria-required, aria-invalid, messages d’erreur) : il ne reste que le contrôle lui-même.

use SolisForms\Domain\Field\AbstractField;
use SolisForms\Support\Sanitizer;

final class Champ_Couleur extends AbstractField {

    public function get_type(): string {
        return 'couleur';
    }

    public function get_label(): string {
        return __( 'Sélecteur de couleur', 'mon-extension' );
    }

    public function get_settings_schema(): array {
        return array(
            'label'    => array( 'type' => 'string', 'default' => '' ),
            'required' => array( 'type' => 'boolean', 'default' => false ),
        );
    }

    public function sanitize( mixed $value ): mixed {
        return sanitize_hex_color( Sanitizer::text( $value ) ) ?? '';
    }

    public function validate( mixed $value, array $config ): array {
        return $this->validate_required( $value, $config );
    }

    protected function render_input( array $config, mixed $value, string $field_id, array $errors ): string {
        return sprintf(
            '<input type="color" class="slf-input"%s value="%s">',
            $this->common_attributes( $config, $field_id, $errors ),
            esc_attr( (string) $value )
        );
    }
}

Enregistrement, depuis le boot() de l’extension ou directement :

add_action( 'solis_forms_register_field_types', function ( $registry ) {
    $registry->register( new Champ_Couleur() );
} );

Le schéma déclaré par get_settings_schema() suffit : le constructeur React génère l’interface de réglage correspondante, sans une ligne de JavaScript. Un schéma de type array produit un éditeur d’options, une clé enum une liste déroulante.

Ajouter un comportement public (JavaScript)

Un champ dont le rendu réclame du JavaScript passe par le registre des modules publics. Le point d’entrée du cœur emprunte exactement ce chemin — logique conditionnelle, calculs, répéteur, paiement s’y déclarent comme le ferait votre extension.

window.solisForms.registerModule( 'mon-extension/signature', ( form, context ) => {
    const champ = form.querySelector( '[data-signature]' );

    if ( ! champ ) {
        return;
    }

    // Retourner une fonction la fait rejouer par context.refresh(), comme les
    // calculs le sont quand une ligne répétable apparaît.
    return () => mettreAJour( champ, context.schema );
} );

Déclarez le script avec solis-forms-frontend en dépendance :

wp_enqueue_script(
    'mon-extension-frontend',
    plugins_url( 'build/frontend.js', __FILE__ ),
    array( 'solis-forms-frontend' ),
    '1.0.0',
    true
);

Trois règles du registre valent d’être connues :

  • Ordre : le troisième argument accepte { priority: 20 }. L’interception de l’envoi est branchée à 100, après tout le reste.
  • Isolation : un module qui lève une erreur est consigné dans la console et ignoré. Il ne prive jamais le formulaire de sa soumission.
  • Arrivée tardive : un module déclaré après le chargement de la page est branché immédiatement sur les formulaires déjà initialisés.

Emprunter les composants du constructeur

Un panneau a souvent besoin de l’éditeur de conditions du cœur, ou du nom de son magasin de données. Les recopier ferait vivre deux éditeurs de conditions, qui divergeraient à la première évolution du format des règles. Le constructeur les publie donc sur window.solisForms.builder :

const { ConditionalLogicEditor, STORE_NAME } = window.solisForms.builder;

Résolvez le composant au rendu plutôt qu’au chargement du module, pour qu’un cœur absent ou plus ancien se traduise par un panneau sans son éditeur, et non par une erreur qui emporterait tout le constructeur. pro/assets/src/builder/core.js en montre le procédé complet.

Ajouter une passerelle de paiement

Ce point d’extension appartient désormais à l’extension payante. Les paiements ont quitté le plugin gratuit : solis_forms_payment_gateways est diffusé par SolisFormsPro\Payments\GatewayRegistry, et une passerelle supplémentaire étend donc SolisForms Pro, non le cœur. Le contrat, lui, n’a pas changé — seuls les espaces de noms sont préfixés SolisFormsPro.

use SolisForms\Payments\PaymentGatewayInterface;

add_filter( 'solis_forms_payment_gateways', function ( array $gateways ) {
    $gateways['mollie'] = new Ma_Passerelle_Mollie();
    return $gateways;
} );

Ma_Passerelle_Mollie implémente les neuf méthodes de PaymentGatewayInterface :

MéthodeRôle
get_id(): stringIdentifiant court, celui employé dans les réglages et les routes
get_label(): stringNom présenté à l’administrateur
is_configured(): boolLes clés nécessaires sont-elles renseignées
supports_subscriptions(): boolIntention de prendre en charge le récurrent
create_payment( PaymentContext $c ): PaymentResultOuvre le paiement
confirm_payment( string $ref ): PaymentResultConfirme après retour du visiteur
verify_webhook_signature( WP_REST_Request $r ): boolAuthentifie l’appel entrant
handle_webhook_event( WP_REST_Request $r ): voidTraite l’événement reçu
refund( string $ref, ?float $amount = null ): PaymentResultRemboursement total ou partiel

Pour les abonnements, implémenter SubscriptionGatewayInterface, qui étend la précédente et y ajoute create_subscription(), fetch_subscription() et cancel_subscription(). Renvoyer true depuis supports_subscriptions() sans implémenter cette interface ne suffit pas : le cœur exige les deux, et écarte la passerelle plutôt que de rompre au milieu d’une souscription.

Deux règles que le cœur applique et qu’une passerelle ne doit pas contourner : le montant est recalculé côté serveur, jamais repris du client ; et la signature d’un webhook est vérifiée avant tout traitement.

Déclarer ses services dans le conteneur

Une extension qui compose plusieurs objets n’a pas à tenir sa propre fabrique : elle déclare ses services dans le conteneur que boot() lui remet. Ils deviennent alors résolvables comme ceux du cœur — donc réutilisables, et remplaçables par une autre extension.

public function boot( Container $container ): void {
    $container->singleton(
        Mon_Depot::class,
        static fn (): Mon_Depot => new Mon_Depot()
    );

    $container->singleton(
        Mon_Rendu::class,
        static fn ( Container $c ): Mon_Rendu => new Mon_Rendu(
            $c->make( Mon_Depot::class ),
            $c->make( EntryValueFormatter::class )   // service du cœur
        )
    );

    add_action( 'init', static fn () => $container->make( Mon_Rendu::class )->register() );
}

pro/src/Views/ViewsAddOn.php en montre un cas complet : huit services, un type de contenu, une route REST, un bloc, un code court et un écran d’administration.

Le piège de la priorité de init

boot() s’exécute pendant init, à la priorité 5. Un abonné ajouté à cette même priorité pendant qu’elle s’exécute ne sera jamais appelé : WP_Hook itère une copie du tableau des abonnés de la priorité courante. Les priorités suivantes, elles, sont bien atteintes.

add_action( 'init', $callback, 5 );   // ✗ jamais exécuté
add_action( 'init', $callback );      // ✓ priorité 10, pas encore atteinte
$this->declare_now();                 // ✓ on est déjà dans init

Ce qui doit précéder vos autres abonnés — un register_post_type() que les suivants consultent — s’appelle donc directement depuis boot() : init a commencé, ce que register_post_type() exige, et l’ordre devient certain.

Réagir à une soumission

add_action( 'solis_forms_entry_created', function ( $event ) {
    wp_remote_post( 'https://exemple.test/crm', array(
        'body' => array(
            'formulaire' => $event->form->title,
            'valeurs'    => $event->entry->values,
        ),
    ) );
} );

L’événement porte l’objet Form complet : inutile de le recharger depuis la base.

Préférez un traitement bref, ou différez-le via wp_schedule_single_event() — ce hook est exécuté pendant la requête de soumission, et l’utilisateur en attend la réponse.

Écrire une intégration sortante

Slack, Brevo et le webhook sortant vivent dans l’extension payante (pro/src/Integrations/) et n’ont aucun accès réservé : ils empruntent le chemin décrit ici, du premier au dernier hook. pro/src/Integrations/Slack/ sert donc d’exemple complet et à jour — c’est un plugin séparé, avec son propre autoloader et son propre domaine de traduction.

Ranger ses réglages

Deux endroits, selon la nature du réglage :

  • Identifiants du compte — clé d’API, jeton : globaux, par le filtre solis_forms_settings_sections, relus par PluginSettings::section().
  • Réglages du formulaire — destinataire, gabarit, condition : par le filtre JavaScript solisForms.formSettingsPanels, relus par FormSettings::section( 'ma-cle' ).

Aucun endpoint REST n’est à écrire : les réglages du formulaire sont persistés par le mécanisme générique, avec le reste du formulaire.

Différer l’envoi

N’appelez jamais un service distant depuis solis_forms_entry_created. Ce hook s’exécute pendant la requête de soumission, dont l’utilisateur attend la réponse : un service lent le ferait patienter, un service en panne le ferait attendre jusqu’au délai d’expiration. La soumission est déjà enregistrée à ce stade, rien ne justifie de lui adosser le sort d’un tiers.

public function boot( Container $container ): void {
    add_action( 'solis_forms_entry_created', function ( $event ) {
        wp_schedule_single_event(
            time(),
            'mon_extension_envoi',
            array( $event->entry->id, $event->form_id, 1 )
        );
    } );

    add_action( 'mon_extension_envoi', function ( $entry_id, $form_id, $attempt ) use ( $container ) {
        // Recomposez la charge utile ici, depuis les dépôts.
    }, 10, 3 );
}

Ne transmettez que des identifiants : recopier des valeurs de soumission dans la table des tâches planifiées les y laisserait en clair.

Services utiles du conteneur

ServiceUsage
FormRepositoryInterface, EntryRepositoryInterfaceRecharger formulaire et soumission au moment de l’envoi
EntryValueFormatterpairs() rend les valeurs avec leur libellé, options résolues et champs sensibles déjà écartés
MergeTagResolverRésoudre {field:…} dans un gabarit
ConditionalLogicEvaluatorAppliquer une condition d’envoi, comme les notifications
OutboundDeliveryEffectuer l’appel : adresse contrôlée, délai borné, résultat consigné
RetryPolicyDécider d’une nouvelle tentative et de son délai

Passer par EntryValueFormatter n’est pas un détail de confort : c’est lui qui écarte les valeurs sensibles. Un mot de passe lu directement dans $entry->values serait expédié au service tiers.

Consigner ses envois

OutboundDelivery écrit dans le journal visible sur le détail d’une soumission. Indiquez votre propre canal, faute de quoi vos envois seraient imputés au webhook générique :

$status = $container->make( OutboundDelivery::class )->send(
    new OutboundRequest(
        'mon-crm',          // canal, pour distinguer vos envois
        $entry_id,
        $form_id,
        $url,
        'POST',
        wp_json_encode( $payload ),
        array( 'Content-Type' => 'application/json' )
    ),
    $attempt
);

if ( RetryPolicy::should_retry( $status, $attempt ) ) {
    // Replanifier avec RetryPolicy::delay( $attempt ).
}

RetryPolicy ne rejoue que les échecs temporaires : une panne réseau, une indisponibilité, un quota atteint. Une requête refusée pour son contenu ne le sera pas moins à la troisième tentative.

Ce qui s’étend sans toucher au cœur

Au-delà des champs, des passerelles et des intégrations, les filtres suivants couvrent le reste du plugin. Chacun est détaillé dans hooks-reference.md.

PourFiltre
Servir ses propres routes RESTsolis_forms_rest_controllers
Ajouter ou réécrire des notificationssolis_forms_notifications
Retoucher un courriel avant envoi (copie, pièce jointe)solis_forms_mail_args
Ouvrir ou fermer un formulaire selon sa propre règlesolis_forms_form_availability
Refuser une soumission que le cœur admettaitsolis_forms_can_submit
Ajouter une colonne au tableau des soumissionssolis_forms_entry_columns, solis_forms_entry_column_value
Ajouter une action de lignesolis_forms_entry_row_actions
Proposer des modèles de formulairessolis_forms_form_templates
Brancher un comportement publicwindow.solisForms.registerModule()
Ajouter un panneau au constructeursolisForms.formSettingsPanels, avec window.solisForms.builder
Remplacer une section du cœursolisForms.formSettingsSections
Ajouter un réglage propre à un type de champsolisForms.fieldInspectorControls
Refuser une soumission avant enregistrementsolis_forms_submission_errors
Traiter une action postée sur une soumissionsolis_forms_entry_action
Ajouter une section au détail d’une soumissionsolis_forms_entry_detail_sections
Purger ses données quand une soumission est effacéesolis_forms_entry_deleting
Ajouter un écran au menuadd_submenu_page() de WordPress, avec FormsListPage::SLUG en parent

Deux d’entre eux imposent une limite délibérée : solis_forms_can_submit ne peut que durcir l’admission d’une soumission, jamais l’assouplir, et une liste de colonnes amputée de sa case à cocher est rejetée en bloc. Dans les deux cas, la valeur du cœur reprend la main.

Repères

  • Liste complète des hooks : hooks-reference.md
  • Organisation du code : architecture.md
  • Découpage gratuit / payant : go-live/decoupage-gratuit-payant.md