blue cube toy lot close-up photography

Comment créer une page d’options WordPress moderne avec React et Gutenberg

Tu connais sûrement le tuto WordPress sur la création de pages d’options avec React et Gutenberg ? Bien que super utile, je te propose aujourd’hui une approche différente qui va te donner les pleins pouvoirs sur ton code.

Pourquoi cette approche ?

On va construire un truc plus costaud avec :

  • React + Gutenberg pour une UI aux petits oignons 👌
  • Notre propre API REST (bye bye les contraintes WordPress)
  • Un système de save qui se fait tout seul

Le plan d’action

Plutôt que de suivre bêtement le chemin classique, on va :

  • Créer une interface qui claque avec les composants Gutenberg
  • Mettre en place notre endpoint d’API perso
  • Construire un système évolutif qui nous facilitera la vie plus tard

    Les points forts de cette méthode

    En développant notre propre route API, on gagne en :

    • Liberté totale sur la structure des données
    • Contrôle complet des validations
    • Flexibilité pour gérer les cas particuliers
    • Maintenabilité à long terme

    Ok, ça demande un peu plus de boulot au début. Mais crois-moi, quand ton projet va grandir, tu seras content d’avoir cette flexibilité !

    Initialisation

    On démarre comme le tuto WordPress, mais ensuite on va pimper tout ça !

    add_action(
    	'admin_menu',
    	function () {
    		add_menu_page(
    			'React Test Plugin Settings',
    			'React Test Plugin 🐝',
    			'manage_options',
    			'react-test-plugin-auto',
    			function () {
    				echo '<div class="wrap"><div id="test-rest-root"></div></div>';
    			},
    			'dashicons-shield-alt'
    		);
    	}
    );
    
    add_action(
    	'admin_enqueue_scripts',
    	function () {
            if ( ! isset( $_GET['page'] ) || $_GET['page'] !== 'react-test-plugin-auto' ) {
              return;
            }
          
          	$get_data_json = get_data_json_sk();
    
    		if ( empty( $get_data_json ) ) {
    			return;
    		}
          
          
            $asset_file = plugin_dir_path( __FILE__ ) . 'js/index.asset.php';
    
            if ( ! file_exists( $asset_file ) ) {
              return;
            }
    
            // Enqueue Gutenberg block editor styles
            wp_enqueue_style( 'wp-components' );
            wp_enqueue_style( 'wp-editor' );
            wp_enqueue_style( 'wp-edit-post' );
            wp_enqueue_style( 'wp-block-editor' );
            wp_enqueue_style( 'wp-format-library' );
            wp_enqueue_style( 'wp-nux' );
    
            $asset = include $asset_file;
    
            wp_enqueue_script(
                'test_root_admin_script_auto',
                plugins_url( 'js/index.js', __FILE__ ),
                $asset['dependencies'],
                $asset['version'],
                array(
                    'in_footer' => true,
                )
            );
    
    
            wp_localize_script(
                'test_root_admin_script_auto',
                'ROOT_ADMIN',
                array(
                  'nonce'    => wp_create_nonce( 'wp_rest' ),
                  'dataJson' => json_encode( $get_data_json ),
                )
            );
    
            wp_enqueue_style(
                'test_root_admin_style_auto',
                plugins_url( 'js/index.css', __FILE__ ),
                array(),
                $asset['version']
            );
        }
    );
    

    Les vérifications de base

    On met en place nos gardes-fous :

    • Check si on est sur la bonne page avec $_GET['page']
    • Vérifie que nos données JSON ne sont pas vides
    • S’assure que notre fichier d’assets existe

    La partie stylée 🎨

    Petite astuce de pro :

    • On récupère directement les styles Gutenberg (pourquoi réinventer la roue ?)
    • Résultat : une interface pro sans se prendre la tête en CSS

    Le setup des scripts

    On balance notre artillerie :

    • Injection de notre script principal
    • wp_localize_script pour notre route API custom
    • Passage de nos champs à créer côté JS

    La touche finale

    Un petit fichier CSS perso, parce que :

    • Y’aura toujours un truc à ajuster
    • Faut bien mettre sa patte quelque part !

    Pro tip : Cette structure te permet d’avoir une base solide pour la suite. On fait les choses proprement dès le début ! 😉

    La function data_json : Le coeur de notre système

    Le Concept 🎯

    On crée une fonction qui va gérer la configuration de nos champs. Simple mais efficace !

    Les types de champs supportés

    Le système gère automatiquement :

    • Input text (le classique)
    • Checkbox (pour les choix binaires)
    • Toggle (plus sexy que la checkbox)
    • Select (pour les listes déroulantes)
    • Textarea (pour les textes longs)
    • Radio (quand il faut choisir UNE option)

    Comment ça marche ?

    1. On définit une config de base
    2. On permet l’override via un hook
    3. On nettoie automatiquement les types non supportés (pour le moment c’est 💩 mais implémente le)

    Attention ⚠️

    Pour l’instant, pas de système de protection ! Si tu joues au petit malin avec le hook et que tu envoies n’importe quoi… ça va partir en cacahuète !

    Mais t’inquiète, la structure est là pour ajouter les validations plus tard. On pose les bases, on sécurisera après ! (Enfin tu sécuriseras 😜)

    function get_data_json_sk() {
    
    	$default = array(
    		'options' => array(
    			'suffix' => 'default',
    		),
    		'fields'  => array(
    			'who_default_field_text'   => array(
    				'label'       => 'Je suis un input text de test',
    				'value'       => '',
    				'type'        => 'text',
    				'placeholder' => 'Je suis un placeholder',
    			),
    			'who_default_field_toogle' => array(
    				'label' => 'Je suis un toogle',
    				'value' => '',
    				'type'  => 'toogle',
    			),
    			'who_default_field_toogle2' => array(
    				'label' => 'Je suis un toogle activé par défaut',
    				'value' => 1,
    				'type'  => 'toogle',
    			),
    			'who_default_field_textarea' => array(
    				'label'       => 'Je suis un textarea',
    				'value'       => '',
    				'type'        => 'textarea',
    				'placeholder' => 'Je suis une placeholder',
    				'rows'        => 5,
    			),
    			'who_default_field_checkbox' => array(
    				'label' => 'Je suis une checkbox',
    				'value' => 0,
    				'type'  => 'checkbox',
    			),
    			'who_default_field_checkbox2' => array(
    				'label' => 'Je suis une checkbox activé par défaut',
    				'value' => 1,
    				'type'  => 'checkbox',
    			),
    			'who_default_field_select' => array(
    				'label' => 'Je suis un select',
    				'value' => 0,
    				'type'  => 'select',
    				'options' => array(
    					array(
    						'value' => 0,
    						'label' => 'Select option',
    						'disabled' => true,
    					),
    					array(
    						'value' => 'option1',
    						'label' => 'Option 1',
    					),
    					array(
    						'value' => 'option2',
    						'label' => 'Option 2',
    					),
    				),
    			),
    		),
    	);
      
    	$data = (array) apply_filters( 'test_root_admin_save_settings_auto', $default );
    
    
    	$data = wp_parse_args( $data, $default );
    
    	if ( ! is_array( $data ) ) {
    		$data = array();
    	}
    
    	return $data;
    }
    

    On couvre déjà les champs les plus courants, ce qui est plutôt pas mal !

    Si tu veux des types de champs plus exotiques, va falloir mettre les mains dans le cambouis. Mais la base est là, à toi de jouer !

    On passe aux routes REST ? 😎

    add_action( 'rest_api_init',
    	function () {
          register_rest_route( 'sk-audo-admin/v1', '/save-settings', array(
              'methods'             => 'POST',
              'callback'            => function ( \WP_REST_Request $request ) {
    
                  $data = get_data_json();
    
                  $name_options = 'who_settings_' . $data['options']['suffix'];
    
                  $saveOptions = get_option( $name_options, array() );
    
                  $params = $request->get_params();
                  foreach ( $params as $key => $param ) {
                      if ( ! isset( $data['fields'][ $key ] ) ) {
                          continue;
                      }
                      $saveOptions[ $key ] = $param;
                  }
    
                  // save options
                  update_option( $name_options, $saveOptions );
    
                  return new \WP_REST_Response( array( 'success' => 1 ), 200 );
              },
              'permission_callback' => function ( \WP_REST_Request $request ) {
                  $nonce = $request->get_header( 'X-WP-Nonce' );
    
                  $verify = 1 === wp_verify_nonce( $nonce, 'wp_rest' );
    
                  return apply_filters( 'test_root_admin_permission_callback_auto', $verify, $request );
              },
          ) );
    
    
    
          register_rest_route( 'sk-audo-admin/v1', '/settings', array(
              'methods'             => 'GET',
              'callback'            => function ( \WP_REST_Request $request ) {
    
    
                  $data = get_data_json();
    
                  $name_options = 'who_settings_' . $data['options']['suffix'];
    
                  $saveOptions = get_option( $name_options, array() );
    
                  return new \WP_REST_Response( $saveOptions, 200 );
              },
              'permission_callback' => function ( \WP_REST_Request $request ) {
                  $nonce = $request->get_header( 'X-WP-Nonce' );
    
                  $verify = 1 === wp_verify_nonce( $nonce, 'wp_rest' );
    
                  return apply_filters( 'test_root_admin_permission_callback_auto', $verify, $request );
              },
          ) );
    	}
    );
    

    La route « save-settings » 💾

    Super simple dans son approche :

    • On prend la liste de nos champs autorisés
    • On compare avec ce qui arrive en POST
    • Si ça correspond, on garde et on sauvegarde
    • Sinon, bye bye !

    Pas de chichi pour l’instant : pas de validation de champs requis ou de règles complexes. C’est basique, mais ça pose les bases pour évoluer tranquillou !

    La route « settings » 📖

    Encore plus basique que la première :

    • Elle récupère juste nos options
    • Les envoie au back-office
    • Point barre !

    Et c’est tout ! Parfois, le plus simple est le plus efficace 😎

    REACT time ! 🎨

    Comment qu’on fait ?

    Les composants Gut qu’on utilise

    • Input text (classique)
    • Checkbox (on/off)
    • Toggle (le switch stylé)
    • Select (la liste déroulante)
    • Textarea (pour les romans)
    • Radio (choix unique)

    La config de nos champs

    On fait matcher nos besoins avec les composants :

    • Mapping entre notre config PHP
    • Et les composants React qu’on a choisis

    Construction de la page

    C’est là que tout s’assemble :

    • Structure de base
    • Placement des composants
    • Gestion des interactions
    import {createRoot, useEffect, Fragment, useState} from "@wordpress/element";
    import {__} from '@wordpress/i18n';
    import apiFetch from '@wordpress/api-fetch';
    import './css/index.scss';
    
    const ROOT_API_PATH = 'sk-audo-admin/v1';
    
    let dataJson = JSON.parse(ROOT_ADMIN.dataJson);
    
    let components = {
        'text': 'TextControl',
        'checkbox': 'CheckboxControl',
        'toogle': 'ToggleControl',
        'select': 'SelectControl',
        'textarea': 'TextareaControl',
        'radio': 'RadioControl',
    }
    
    // On vérifie que chaque champ configuré correspond à nos types supportés
    // Si ça match pas, on le vire
    // Pas de compromis : soit c'est dans la liste des possibles, soit ça dégage !
    Object.entries(dataJson.fields).map(([key, field]) => {
        if (!components[field.type]) {
            delete dataJson.fields[key];
        }
    });
    
    // Pas de champs ? Pas de page ! Pas de page ? Pas de code ! Pas de code... Bah pas de code, hein ! 🤷‍
    if (Object.keys(dataJson.fields).length === 0) {
        throw new Error('dataJson.fields is empty');
    }
    
    
    import {
        ToggleControl,
        Icon,
        Button,
        Spinner,
        CheckboxControl,
        TextControl,
        SelectControl,
        TextareaControl,
        RadioControl
    } from '@wordpress/components';
    
    
    const App = () => { } // La logique : c'est ici que ça devient intéressant ! 💪
    
    
    // Ok, tout est prêt ? Tout est validé ? Alors BOOM : on lance l'app ! 🚀
    document.addEventListener('DOMContentLoaded', () => {
        const htmlOutput = document.getElementById('test-rest-root');
    
        if (htmlOutput) {
            const root = createRoot(htmlOutput);
            root.render(
                <App/>
            );
        }
    });
    
    • On commence par les classiques : « Allez, ramène tous les imports qu’on va avoir besoin ! »
    • Petite config rapide : « 2-3 réglages vite fait, bien fait »
    • Les composants, c’est pas open bar : « On définit la liste des composants autorisés, et on déconne pas avec ça ! »
    • Phase de contrôle : « On vérifie que tout est nickel chrome »
    • Le moment de vérité : « Tout est ok ? On balance la page ! »

    L’App 🎯

    const App = () => {
        const [isDisabledButton, setIsDisabledButton] = useState(false);
        const [isNotice, setNotice] = useState(false);
        const [isLoading, setIsLoading] = useState(true);
    
        // 1. On démarre à zéro : init du state !
        const [state, setState] = useState(
            Object.fromEntries(
                Object.entries(dataJson.fields).map(([key, field]) => [key, field.value])
            )
        );
    
        const headers = new Headers({
            'Content-Type': 'application/json',
            'X-WP-Nonce': ROOT_ADMIN.nonce,
        });
    
        // 2. On check si y'a déjà des trucs en stock
        const setData = () => {
            setIsLoading(true);
            apiFetch({
                path: ROOT_API_PATH + '/settings',
                method: 'GET',
            }).then((res) => {
                setState(prevState => ({...prevState, ...res}));
            }).finally(() => {
                setIsLoading(false);
            });
        }
    
        // 3. L'app se lance, on récup tout ce qu'on a besoin
        useEffect(() => {
            setData();
        }, []);
    
    
        // 4. Un truc bouge ? On réagit !
        const handleChange = (key) => (value) => {
            setState(prevState => ({...prevState, [key]: value}));
        };
    
    
        // 5. Click de save ? On balance tout en base !
        const saveSettings = () => {
            setIsDisabledButton(true);
            apiFetch({
                path: ROOT_API_PATH + '/save-settings',
                method: 'POST',
                headers: headers,
                data: state,
            }).then((res) => {
                setNotice(true);
                // après 1 seconde, pouff plus de notice
                setTimeout(() => {
                    setNotice(false);
                    setIsDisabledButton(false);
                }, 1000);
            });
        }
    
        // 6. La partie fun : on balance nos champs !
        let dataJsonArray = Object.entries(dataJson.fields).map(([key, field]) => {
            if (field.type === 'text') {
                return (
                    <TextControl
                        key={key}
                        placeholder={field.placeholder ? field.placeholder : ''}
                        label={field.label}
                        value={state[key]}
                        onChange={handleChange(key)}
                    />
                );
            }
    
            if (field.type === 'checkbox') {
                return (
                    <CheckboxControl
                        key={key}
                        label={field.label}
                        checked={state[key]}
                        onChange={handleChange(key)}
                    />
                );
            }
    
            if (field.type === 'toogle') {
                return (
                    <ToggleControl
                        key={key}
                        label={field.label}
                        checked={state[key]}
                        onChange={handleChange(key)}
                    />
                );
            }
    
            if (field.type === 'select') {
                return (
                    <SelectControl
                        key={key}
                        label={field.label}
                        value={state[key]}
                        options={field.options}
                        onChange={handleChange(key)}
                    />
                );
            }
    
            if (field.type === 'textarea') {
                return (
                    <TextareaControl
                        key={key}
                        placeholder={field.placeholder ? field.placeholder : ''}
                        rows={field.rows ? field.rows : 3}
                        label={field.label}
                        value={state[key]}
                        onChange={handleChange(key)}
                    />
                );
            }
    
            if (field.type === 'radio') {
                return (
                    <RadioControl
                        key={key}
                        label={field.label}
                        selected={state[key]}
                        options={field.options}
                        onChange={handleChange(key)}
                    />
                );
            }
        });
    
        return (
            <Fragment>
                <h1>{__('Settings', 'test-rest-root')}</h1>
                {isLoading ? (
                    <div className="loader">
                        <Spinner/>
                    </div>
                ) : (
                    <Fragment>
                        {dataJsonArray}
    
                        <Button
                            onClick={() => {
                                saveSettings();
                            }}
                            className={isNotice ? 'is-success-whoSettings' : ''}
                            disabled={isDisabledButton}
                            isPrimary
                        >{isNotice ?
                            <Fragment>
                                {__('Saved', 'who')} <Icon icon="yes" size={30}/>
                            </Fragment>
                            :
                            __('Save', 'who')}
                            {isDisabledButton && !isNotice && (
                                <Spinner/>
                            )}
                        </Button>
                    </Fragment>
                )}
            </Fragment>
        );
    };
    

    La base de notre app

    1. On part sur du useState comme des pros :
      • D’abord les flags true/false
      • Et on boucle sur tous les champs comme des malades ! 🔄
    2. Direction la BDD, on ramène le butin !
    3. useEffect aux commandes : dès que l’app démarre, on charge tout
    4. La fonction magique d’update : elle gère l’avant/après comme un chef
    5. Le bouton save qui fait vroum : un click et c’est parti !
    6. Création de tous nos champs, un par un

    La Cerise sur le gâteau 🍒

    Le return :

    • App pas prête ? → Petit loader qui tourne
    • Tout est ok ? → BAM ! Les champs + le bouton save

    Et voilà le travail ! Plutôt clean non ? 😎

    Disclaimer de Fin 🎯

    « Ça marche chez moi » – la phrase la plus dangereuse du dev ! 😅, mais chez toi …. qui sait ! 🤷‍♂️

    Toi-même tu sais ! 😉

    Samy Kantari - Expert WordPress + IA

    Kantari Samy

    Expert WordPress + IA

    👨‍💻 10 ans dans le game WordPress, chez Whodunit, à bricoler du code, à dompter des bugs et à faire tourner des projets de toutes tailles.
    Puis l’IA est arrivée… et là, révélation 💡 !
    J’ai switché de mindset, réinventé ma façon de coder et avec le vibe coding : une nouvelle ère où je ne suis plus limité par le temps ni par les outils.

    Aujourd’hui ? Je code toujours… Mais avec mon copilote IA.
    On forme une team de choc. Lui, c’est la puissance. Moi, c’est la vision. Ensemble, on déverrouille ce qui semblait impossible hier. 🚀

    10+ Années d'expérience
    +++ Projets réalisés
    80% code par IA
    S’abonner
    Notification pour
    guest
    0 Commentaires