Créer un tableau de status pour vos composants dans Storybook

⚠️ La totalité de cet article a été rédigé pour être mis en place avec @storybook/html en utilisant le format MDX.

Le contexte

Notre besoin était de visualiser les différents status (UI, HTML – Vanilla, A11Y, Doc, Framework) de l’ensemble de nos composants à l’aide d’un tableau récapitulatif.

La centralisation de l’information avait pour objectif d’offrir de l’autonomie aux personnes pour trouver et lire facilement les informations qu’ils recherchaient. Evitant ainsi les A/R entre Confluence, Figma et Storybook.

Le résultat attendu était le suivant :

Les contraintes

  • Le tableau doit être gérer par une page Storybook au format MDX.
  • La solution doit être évolutive (nouveau status, nouveau framework, etc).
  • La mise à jour des status d’un composant doit être simple.
  • Notre bibliothèque est monobloc et nos composants ne disposent pas d’un package.json indépendant.

Les implémentations

1. La simplicité

La première idée fut la plus simple : un tableau HTML statique dans le fichier MDX. Quelqu’un a dit « Un tableau HTML c’est pas si compliqué à maintenir » 😂 D’autres ne m’ont pas cru…

Même si la mise en place a été plutot rapide et plébicitée, ce fut rapidement un échec sans surprise.

Ce fut une vraie galère à maintenir et faire évoluer même avec seulement une dizaine de composants.

Il y aurait bien eu la solution de gestion de tableaux HTML avec un outil en ligne mais l’idée a été tuée avant d’y investir cinq minutes.

2. La praticité

Pour que toute notre histoire devienne pratique, il nous fallait savoir trois choses :

  1. Storybook est-il en mesure de nous fournir la liste de toutes nos stories ?
  2. Où peut-on stocker les informations dans une story ?
  3. Peut-on rendre un tableau HTML dynamique dans un fichier MDX avec Storybook ?

1. La réponse est oui

Comme je n’ai absolument rien tracé pendant mes recherches, je ne sais plus vraiment comment je suis arrivé à trouver ça. Certainement en passant quelques heures sur GitHub et Stack Overflow.

Le paquet @storybook/html met à disposition deux méthodes pour récupérer l’ensemble des contenus :

  • getStorybook va retourner un tableau contenant l’ensemble des stories et des pages. Malheureusement pour nous, les informations retournées sont limitées et ne vont pas couvrir notre futur besoin.

  • raw va retourner un tableau contenant uniquement l’ensemble des stories avec toutes leurs informations. 🥳


J’ai encore quelques regrets sur le fait de ne pas avoir trouvé de solution avec la méthode getStorybook surtout quand je sais qu’il existe un @deprecated devant la méthode raw ici.

Il existe bien une méthode selectStory dans le paquet @storybook/api mais rien ne semble fonctionnel au niveau d’une story (j’ai peut être loupé un truc) 😠. Alors que ça semble tout à fait possible dans une addon : Addon API – useStorybookApi [en]

Pour aller au bout de notre idée, nous profiterons de raw tant que la méthode est disponible 🙏

2. Les paramètres d’une story sont sans limite

On trouve souvent notre bonheur et notre délivrance dans la documentation d’un projet. Dans notre cas, ce sera l’objet parameters [en]. Un espace de stockage qui va servir à différentes choses dans Storybook et qui semble sans restriction. Let’s go.

L’autre avantage de passer par cet objet est la notion d’héritage (cf. : Rules of parameter inheritance [en]). Celle qui nous intéressera le plus dans notre cas sera celle entre le composant et ses stories.

Du coup, dans chaque composant au niveau voulu (composant, story ou les deux), nous aurons dans parameters un objet de la forme :

parameters={{
  uiLibraryName: {
    status: {
      ui: ['ready'],
      html: ['ready', 'includes-js'],
      a11y: [
        {
          value: 'ready',
          description: 'modèle [accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) "Accordion (Sections With Show/Hide Functionality) - ARIA Authoring Practices")',
        }
      ],
      docs: ['in-progress'],
      frameworkA: ['no-available'],
      frameworkB: ['in-progress'],
      frameworkC: ['scheduled'],
    }
  }
}}

Ce dernier nous pemettra d’avoir la liste de tous nos status avec la possibilité d’avoir un ou N états pour chacun. Nous avons également laissé la possibilité de saisir une description pour un état à l’aide d’un objet (qui accepte du Markdown 😛).

Autre détail, les status ne sont pas à la racine de notre objet uiLibraryName (vous y donnerez le nom que vous voulez) pour se laisser d’autres possibilités dans le futur.

3. Avec html-react-parser rien ne semble impossible

Le paquet html-react-parser nous sert déjà à afficher le résultat d’une story ou plutôt de son template à l’intérieur d’un élément <Canvas> pour ne pas polluer l’arborescence dans la sidebar (cf. : storybookjs/storybook#9209 (comment))

Du coup, nous n’avons pas cherché plus loin pour la création de notre tableau.

Voici l’exemple pour notre légende :

import htmlReactParser from 'html-react-parser'
import { Meta } from '@storybook/addon-docs'
import includeJS from './assets/media/js.svg'

export const componentStatus = {
  scheduled: { icon: '🚀', label: 'Programmé' },
  ready: { icon: '✅', label: 'Validé' },
  'in-progress': { icon: '⏳', label: 'En cours' },
  alert: { icon: '⚠️', label: 'Point de vigilance' },
  blocked: { icon: '🔒', label: 'Bloqué' },
  'not-available': { icon: '➖', label: 'Non implémenté' },
  experimental: { icon: '🧪', label: 'Expérimental' },
  'includes-js': {
    icon: `<img src="${includeJS}" style="width:1.25em;height:1.25em;vertical-align:middle" />`,
    label: 'Inclus du JavaScript',
  },
}
 
<Meta
  title="Composants/Status des composants"
  parameters={{
    options: { isToolshown: false },
  }}
/>

# Statut des composants
 
<br />
 
**Légende** :
 
<ul>
  {htmlReactParser(Object.values(componentStatus)
    .map(
      (s, i) =>
        `<li style="font-size:14px${i ? ';margin-top:.25em' : ''}">${
          s.icon
        }&nbsp;${s.label}</li>`
    )
    .join(''))}
</ul>

4. Le petit tips pour le plaisir

Pour restituer le markdown dans la description de nos status, nous avons utilisés le paquet marked.

La dernière petite chose qui nous resterait à faire et de gérer l’attribut target="_blank" grâce à ce contournement : markedjs/marked#655 (comment). En attendant, on va expliquer aux gens qu’on peut faire ça aussi avec un raccourci clavier 😉

En espérant que tout cela pourra vous être utile :bowtie:

La page complète est dispo juste ici : https://gist.githubusercontent.com/7studio/6af8612b5574d58f458a5a8afc0b626b/raw/1bd27b26e77c21da3f124a2c957c4001c40dbeeb/1.components.stories.mdx

Administrer le quatrième format d’images de WordPress

Le sujet des images dans WordPress peut être sensible surtout quand on se préoccupe un peu de son espace disque 😅

Si on laisse de côté les thèmes premium et leur mille formats d’images générés pour pas grand chose (je hais les thèmes premium !) , il faut savoir que lorsqu’on téléverse une image dans WordPress ce dernier va par défaut en créer quatre autres.
Même s’il vous semble que l’administration WordPress vous offre la possibilité de préciser vos tailles d’images : « Taille des miniatures », « Taille moyenne » et « Grande taille », WordPress gère en fait une quatrième taille medium_large qu’il utilise pour les images responsives 🤔

Si vous avez loupé ce détail datant du 22 décembre 2015 (version 4.4 de WordPress), voici l’article qui y fait référence : Responsive Images in WordPress 4.4.

Du coup, en parcourant le code du cœur, il est facile de trouver dans wp-admin/includes/schema.php les deux options qui font référence à la taille de cette image :

// 4.4.0
'medium_large_size_w' => 768,
'medium_large_size_h' => 0,

Je n’ai pas poussé jusqu’à trouver le ticket qui fait référence à ce choix de 768px mais je suis presque sûr que c’est parce que cela correspond à la largeur CSS d’un Ipad 😆
Quel malheur 😥

Comme j’ai pu être mauvaise langue 😜 en disant que je n’avais jamais rien trouvé dans le codex au sujet de ce format, je m’en excuse car tout y est, il suffit d’ouvrir les yeux : Featured Images & Post Thumbnails

Bon bé maintenant qu’on est au courant, il ne reste plus qu’à nous offrir la possibilité de préciser les dimensions maximales (en pixels) à utiliser pour ce fameux format « Medium-large » comme ci-dessous.

La première chose à faire est de rajouter les champs de saisie au bon endroit :

/**
 * Adds a new field into the media settings page to manage
 * the fourth image size: "Medium Large".
 *
 * Introduced by WordPress 4.4, this format has by default a `768px` width
 * and is used for responsive images (through the `srcset` attribute).
 * I know that it's not a good idea to define `srcset` and `size`
 * attributes according to this site breakpoint
 * (cf.: )
 * but as WP handles this format (and uses space disc) without telling us,
 * we should be able to edit it to enjoy it even for one of our breakpoints.
 *
 * If you want to escape this format, you just have to set its width
 * and height to zero ;)
 */
function thistle_add_medium_large_size_settings() {
    add_settings_field(
        'medium_large_size',
        __( 'Large size' ),
        '_thistle_output_medium_large_size_settings',
        'media',
        'default',
        array()
    );
}

function _thistle_output_medium_large_size_settings() {
    ?>
    <fieldset>
        <legend class="screen-reader-text">
            <span><?php _e( 'Large size' ); ?></span>
        </legend>
        <label for="medium_large_size_w"><?php _e( 'Max Width' ); ?></label>
        <input name="medium_large_size_w" type="number" step="1" min="0" id="medium_large_size_w" value="<?php form_option( 'medium_large_size_w' ); ?>" class="small-text" />
        <br>
        <label for="medium_large_size_h"><?php _e( 'Max Height' ); ?></label>
        <input name="medium_large_size_h" type="number" step="1" min="0" id="medium_large_size_h" value="<?php form_option( 'medium_large_size_h' ); ?>" class="small-text" />
    </fieldset>
    <?php
}
add_action( 'admin_init', 'thistle_add_medium_large_size_settings' );

Je l’avoue, j’ai pris une initiative supplémentaire dans mon code 😚

Comme en bon François « Medium-large » ne semble pas avoir d’équivalent, j’ai décidé que ce format s’appellerait maintenant « Grande taille » et que je renommerais le précédent « Grande taille » en « Très grande taille » 🤓

Procédons donc au renommage :

/**
 * Renames "Large" label into "Extra large" via JavaScript 
 * because we can't do it with the help of PHP.
 */
function thistle_rename_large_size_settings() {
    ?>
    <script>
        ( function( window, $, undefined ) {
            if ( typeof $ !== 'undefined' ) {
                $( document ).ready( function () {
                    var $lss = $( '#large_size_w' ).parents('tr');

                    $lss
                        .find('th, legend span')
                            .html('<?php _e( 'Extra large size', YOUR_TEXT_DOMAIN ); ?>');
                } );
            }
        } )( window, window.jQuery );
    </script>
    <?php
}
add_action( 'admin_head-options-media.php', 'thistle_rename_large_size_settings' );

/**
 * Retrieves the names and labels of the default image sizes including
 * the fourth (or fifth) one: "Medium Large".
 * Because we don't have a good translation for "Medium Large" in French,
 * I decided to rename "Large" into "Extra large" and
 * give the "Large" label to the medium_large size.
 *
 * @param array $size_names Array of image sizes and their names. Default values
 *                          include 'Thumbnail', 'Medium', 'Large', 'Full Size'.
 * @return array
 */
function thistle_change_image_size_names( $size_names ) {
    $index = array_search( 'large' , array_keys( $size_names ) );

    $size_names = array_merge( array_slice( $size_names, 0, $index, true), array( 'medium_large' => _( 'Large' ) ), $size_names );
    $size_names['large'] = __( 'Extra large', YOUR_TEXT_DOMAIN );

    return $size_names;
}
add_filter( 'image_size_names_choose', 'thistle_change_image_size_names' );

Je sais que ce choix peut être sujet à un troll monumental mais je l’assume 😎 On aurait aussi pu traduire « Medium-large » par « Taille intermédiaire » mais bon.

Nous y sommes presque.
Quand nous avons ajouté nos champs via add_settings_field, ces derniers se sont placés à la fin de notre page et ce n’est pas vraiment logique par rapport à l’ordre des tailles d’images.

Remettons cela dans le bon ordre :

/**
 * Changes the order of the size settings to have "Medium Large" before
 * "Large" via JavaScript because we can't do it with the help of PHP.
 */
function thistle_change_size_settings_order() {
    ?>
    <script>
        ( function( window, $, undefined ) {
            if ( typeof $ !== 'undefined' ) {
                $( document ).ready( function () {
                    var $lss = $( '#large_size_w' ).parents('tr');
                    var $mlss = $( '#medium_large_size_w' ).parents('tr');

                    $lss
                        .insertAfter($mlss);
                } );
            }
        } )( window, window.jQuery );
    </script>
    <?php
}
add_action( 'admin_head-options-media.php', 'thistle_change_size_settings_order' );

Je ne vous cache pas que l’utilisation de JavaScript pour faire ce genre de choses ne m’enchante jamais mais quand WordPress ne nous laisse pas le choix… 😩

Sauf que, même si nos nouveaux champs se trouvent bien sur notre page d’options et  sont dans le bon ordre, les nouvelles dimensions ne s’enregistrent pas en base de données 🤔
Pour que cela fonctionne, nous devons whitelister celles-ci de la façon suivante :

/**
 * Adds the `medium_large_size_w` and `medium_large_size_h` options
 * to the white list to be authorised to edit them.
 *
 * @param array White list options.
 * @return array.
 */
function thistle_media_whitelist_options( $whitelist_options ) {
    $whitelist_options['media'][] = 'medium_large_size_w';
    $whitelist_options['media'][] = 'medium_large_size_h';
    
    return $whitelist_options;
}
add_filter( 'whitelist_options', 'thistle_media_whitelist_options' );

Voila, c’est fini 😁

Créer des raccourcis vers des pages « importantes » dans le menu d’administration de WordPress

Ce besoin est venu d’une ancienne chef de projet qui en avait marre de chercher certaines pages « importantes » dans la liste des pages 😄 Ça peut se comprendre. Entre nous, avec l’extension Simple Page Ordering, elle aurait pu remonter les pages concernées tout en haut mais pas question (pour tout le monde) de voir la liste des pages dans le plan du site (via wp_list_pages) être impactée par ce choix/besoin côté back-office.

Alors d’un commun accord, nous avons décidé d’ajouter des raccourcis dans le menu « Pages » du back-office de WordPress. Juste entre l’entrée « Toutes les pages » et « Ajouter » pour que ça soit plus « pratique » et « logique » 😅 Je savais que ça risquerait d’être chiant mais j’étais d’accord avec elle 👍 De toutes façons, quand je réfléchis avec les CdP, j’évite de penser à la technique car ça bloque/tend souvent les discussions.

Donc voilà comment j’ai répondu à notre besoin :

La première chose à faire est de pouvoir récupérer et modifier une liste des pages qui vont faire l’objet d’un raccourci en tant que sous-menu. 
Par défaut, nous avons décidé d’ajouter la page d’accueil ainsi que la page « Politique de confidentialité ».

/**
 * Retrieves the list of pages which will have a shortcut into the "Page"
 * admin menu.
 *
 * @param void
 * @return array An associate array (`post_id => label`) of pages.
 */
function thistle_get_admin_submenu_page_shortcuts() {
    $shortcuts =  apply_filters( 'thistle_admin_submenu_page_shortcuts', array(
		get_option( 'page_on_front' )              => __( 'Front Page' ),
		get_option( 'wp_page_for_privacy_policy' ) => __( 'Privacy Policy' )
    ) );

    // Be sure that the page ID asked for is not zero ;)
    $shortcuts = array_filter( $shortcuts, function( $sk ) { return !! intval( $sk ); }, ARRAY_FILTER_USE_KEY );

    return is_array( $shortcuts ) ? $shortcuts : array();
}

La façon la plus « propre » et responsable d’ajouter ces pages serait d’utiliser la fonction add_pages_page. Mais malheureusement pour nous, cette fonction ne nous permet pas de choisir la position de nos éléments… La faute à add_submenu_page qui ne gère pas de priorité/position contrairement à sa grande soeur add_menu_page 😩 On garde les doigts croisés (ça ne fait qu’un an et demi) pour que le ticket 39776 fasse parti de WordPress 5.0 mais en attendant il va falloir bricoler… 
J’ai donc décidé de me mettre sur l’action admin_menu et d’attaquer directement la variable globale $submenu sur l’index edit.php?post_type=page 😬 Sauf que même si ça parait plutot simple, il y a un hic 😉 
Voici comment est organisé le tableau pour construire le sous-menu :

Array
(
    [5] => Array
        (
            [0] => All pages
            [1] => edit_pages
            [2] => edit.php?post_type=page
        )

    [10] => Array
        (
            [0] => Add new
            [1] => edit_pages
            [2] => post-new.php?post_type=page
        )

)

Même si cela ne me choque pas vraiment (cf. : Menu Structure), il va falloir tenir compte de ce détail pour que notre cinquième raccourci n’écrase/efface pas l’entrée « Ajouter ». 
Donc à la place d’insérer nos raccourcis sur un pas de 1, nous les ajouterons sur un pas de 0.1 😜 Sachez le, la fonction add_menu_page accepte déjà ce genre de valeurs (cf. : add_menu_page supports decimal positions) et c’est super pratique.

/**
 * Adds page shortcuts into the "Page" admin menu between "All Pages" and "Add new".
 *
 * @param void
 * @return void
 */
function thistle_add_admin_submenu_page_shortcuts() {
    global $submenu, $wp_post_types;

    $page_shortcuts = thistle_get_admin_submenu_page_shortcuts();
    $page_shortcuts_count = count( $page_shortcuts );

    // Exit early if no shortcut is needed.
    if ( empty( $page_shortcuts ) ) {
        return;
    }

    // Start to add shortcuts just after "all Pages" submenu.
    $index = (int) array_search( $wp_post_types['page']->labels->all_items, array_combine( array_keys( $submenu['edit.php?post_type=page'] ), array_column( $submenu['edit.php?post_type=page'], 0 ) ) );

    foreach ( $page_shortcuts as $id => $label ) {
        $index += .1;

        $submenu['edit.php?post_type=page'][(string) $index] = array(
            $label,
            'edit_pages',
            'post.php?post=' . $id . '&action=edit'
        );

      //add_pages_page( $label, $label, 'edit_pages', 'post.php?post=' . $id . '&action=edit', (string) $index );
    }

    ksort( $submenu['edit.php?post_type=page'] );
}
add_action( 'admin_menu', 'thistle_add_admin_submenu_page_shortcuts', 5 );

Il ne reste plus qu’à sélectionner/illuminer le bon sous-menu car par défaut lors de l’édition d’un contenu, WordPress sélectionne/illumine l’entrée « générale » (dans notre cas « Toutes les pages »). 
Pour faire cela, il y a plusieurs écoles : via admin_head, via parent_file ou celle ci-dessous 😙

/**
 * Highlights the shortcut submenu into the "Page" admin menu instead of "All pages".
 *
 * @param string $submenu_file The submenu file.
 * @param string $parent_file  The submenu item's parent file.
 * @return string
 */
function thistle_highlight_admin_submenu_page_shortcut( $submenu_file, $parent_file ) {
    global $post_type;

    $page_shortcuts = thistle_get_admin_submenu_page_shortcuts();

    if ( $post_type == 'page' && isset( $_GET['post'], $_GET['action'] ) && array_key_exists( $_GET['post'], $page_shortcuts ) && $_GET['action'] == 'edit' ) {
        return 'post.php?post=' . $_GET['post'] . '&action=edit';
    }

    return $submenu_file;
}
add_filter( 'submenu_file', 'thistle_highlight_admin_submenu_page_shortcut', 10, 2 );

Cela n’était pas notre besoin lors du développement mais l’ensemble de ce code pourrait tout à fait être fonctionnel pour l’ensemble des types de contenu avec quelques ajustements.

Voila, c’est fini 😁

Cacher son URL de connexion dans les emails RGPD de WordPress (et un peu plus)

Nous le savons, WordPress est un outil formidable qui propose des évolutions toujours appréciables (ou pas). La dernière en date, ne déroge pas à la règle surtout quand elle concerne la conformité RGPD 😃 
Si vous n’avez pas encore fait le tour du sujet, voici un article de monsieur Jb Audras : WordPress version 4.9.6/7 : de nouveaux outils pour l’application du RGPD, un autre article concernant WooCommerce : WooCommerce 3.4 GDPR featureset la liste des choses à venir : Proposed Privacy Roadmap 
C’est quand même cool tout ça !

Comme toutes nouvelles fonctionnalités, même si celle-ci facilite bien la vie aux administrateurs grâce à une interface soignée, l’envers du décor pour les développeurs est un peu moins réjouissant :

  1. WordPress ne fournit aucune aide pour enregistrer une demande venant de votre site. Pourquoi WP le fait-il pour les commentaires et pas avec ça finalement ? Je vous rassure, la communauté a déjà pensé à ce point : GDPR Data Request Form
  2. La page « Politique de confidentialité » est considérée comme uniquement éditable par les administrateurs : https://boiteaweb.fr/manage-privacy-options-roles-gdpr-105235.html
  3. Lorsqu’un utilisateur WP demande la suppression de ses données, son compte n’est ni supprimé, ni anonymisé et ses contenus lui sont toujours associés (vaste sujet hein).
  4. Tout le reste… Exemple le ticket 44354 et ceux listés dedans.

Mais le plus problématique pour moi à ce moment est la divulgation de l’URL de connexion dans un email « public »
Voici ce que WordPress envoye aux visiteurs (n’importe qui) qui vont effectuer une demande d’export :

Bonjour,

Une demande a été faite afin d’effectuer les actions suivantes sur votre compte :

Exporter les données

Pour confirmer cela, veuillez cliquer sur le lien suivant :
https://www.monsite.test/wp-admin/wp-login.php?action=confirmaction&request_id=289&confirm_key=8Gq7SGilB2blScR7owW4

Si vous ne souhaitez pas effectuer cette action, vous pouvez ignorer et supprimer cet e-mail en tout sécurité.

Cet e-mail a été envoyé à xavier@monsite.test.

Cordialement,
L’équipe de monsite_test
https://www.monsite.test

Quel malheur de voir apparaître son URL de connexion et cela même si on utilise une extension pour la cacher 😥 
En attendant que les extentions concernées (ex.: Move Login) se mettent à jour et pour ceux qui n’utilisent pas d’extension, voici comment j’ai abordé le sujet/problème :

Avant toutes choses, nous allons demander à WP de stocker les archives d’exports ailleurs que dans le répertoire par défaut wp-personal-data-exports. C’est un peu parano mais après tout ce sont des données bien plus sensibles que les images de notre site.

/**
 * Returns the directory used to store personal data export files.
 *
 * @return string Exports directory.
 */
function thistle_privacy_exports_dir() {
    $upload_dir  = wp_upload_dir();
    $exports_dir = trailingslashit( $upload_dir['basedir'] ) . 'gprd-exports/';

    return apply_filters( 'thistle_privacy_exports_dir', $exports_dir );
}

/**
 * Returns the URL of the directory used to store personal data export files.
 *
 * @return string Exports directory URL.
 */
function thistle_privacy_exports_url() {
    $upload_dir  = wp_upload_dir();
    $exports_url = trailingslashit( $upload_dir['baseurl'] ) . 'gprd-exports/';

    return apply_filters( 'thistle_privacy_exports_url', $exports_url );
}

/**
 * Overrides some default WP choices:
 * - the directory used to store personal data export files.
 * - the URL of the directory used to store personal data export files.
 */
add_filter( 'wp_privacy_exports_dir', 'thistle_privacy_exports_dir' );
add_filter( 'wp_privacy_exports_url', 'thistle_privacy_exports_url' );

Evidemment, vous pouvez remplacer gprd-exports par ce que vous voulez 😉

Maintenant, le plus important, nous allons demander à WP d’enregistrer deux alias :

  1. le premier pour cacher wp-login.php. Ce qui signifie qu’en nous rendant à cette adresse https://www.monsite.test/donnes-personnelles/, nous verrons notre page de confirmation d’action RGPD.
  2. le second pour cacher le dossier où sont stockées les archives des données exportées. Les archives stockées dans le répertoire gprd-exports seront donc aussi accessibles sur l’URL https://www.monsite.test/donnes-personnelles/export/*.zip.
/**
 *
 *
 * @return string
 */
function thistle_gprd_make_link_relative( $link ) {
  return ltrim( wp_make_link_relative( $link ), '/' );
}

/**
 * Returns the public URL which will be used by the users to confirm
 * their action about their personal data.
 *
 * @return string
 */
function thistle_privacy_confirm_public_url() {
  $confirm_url = home_url( 'donnees-personnelles/' );

  return apply_filters( 'thistle_privacy_confirm_public_url', $confirm_url );
}

/**
 * Returns the public URL which will be used by the users to download
 * their personal data export file.
 *
 * @return string
 */
function thistle_privacy_exports_public_url() {
  $exports_url = trailingslashit( thistle_privacy_confirm_public_url() ) . 'export/';

  return apply_filters( 'thistle_privacy_exports_public_url', $exports_url );
}

/**
 * Adds rewrite rules to hide :
 *  - the URL of login page
 *  - the URL of the directory used to store personal data export files.
 *
 * for non-registered users. It's a bit paranoiac, but if you have activated a plugin
 * to hide/move your login page, you will not want to display it in a public email ;)
 *
 * @param void
 * @return void
 */
function thistle_rewrite_privacy_urls() {
    // Export URL
    $exports_rel_url = thistle_gprd_make_link_relative( thistle_privacy_exports_public_url() );
    $wp_privacy_exports_rel_url = thistle_gprd_make_link_relative( wp_privacy_exports_url() );

    add_rewrite_rule( $exports_rel_url . '([^/]+)\.zip$', $wp_privacy_exports_rel_url . '$1.zip', 'top' );

    // Confirm URL
    $confirm_rel_url = thistle_gprd_make_link_relative( thistle_privacy_confirm_public_url() );
    $wp_privacy_confirm_rel_url = thistle_gprd_make_link_relative( site_url() . '/wp-login.php' );

    add_rewrite_rule( $confirm_rel_url . '?$', $wp_privacy_confirm_rel_url, 'top' );
}
add_action( 'init', 'thistle_rewrite_privacy_urls' );

Ces règles concidérées comme non_wp_rules (car elles ne concernent pas index.php) iront s’écrire dans votre fichier .htaccess entre # BEGIN WordPress et # END WordPress comme ci-dessous :

# BEGIN WordPress
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteRule ^donnees-personnelles/export/([^/]+)\.zip$ /wp-content/uploads/gprd-exports/$1.zip [QSA,L]
RewriteRule ^donnees-personnelles/?$ /wp-admin/wp-login.php [QSA,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

# END WordPress

C’est bien joli d’avoir cette nouvelle URL https://www.monsite.test/donnes-personnelles/ mais il va falloir imposer quelques règles pour que les gens mal-intentionnés ne puissent pas y accéder directement et voir notre boite de connexion. De toute façon, il n’y a aucun intérêt à accéder à cette page si ce n’est pas pour confirmer une action RGPD.

/**
 * Handles access to the new public URL which receives confirm actions from users for their personal data.
 *
 * Forbids direct or wrong formatted requests.
 *
 * @param void
 * @return void
 */
function thistle_handle_privacy_confirm_action() {
  $confirm_url_pattern = '/' . trim( thistle_gprd_make_link_relative( thistle_privacy_confirm_public_url() ), '/' );
  if ( mb_strpos( $_SERVER['REQUEST_URI'], $confirm_url_pattern ) !== 0 ) {
    return;
  }

  $wp_privacy_action = isset( $_REQUEST['action'], $_REQUEST['request_id'], $_REQUEST['confirm_key'] ) && ! empty( $_REQUEST['action'] ) && ! empty( $_REQUEST['request_id'] ) && ! empty( $_REQUEST['confirm_key'] );
  if ( ! $wp_privacy_action ) {
    wp_die( __( 'Invalid request.' ) );
  }

  if ( $_REQUEST['action'] != 'confirmaction' || ! intval( $_REQUEST['request_id'] ) ) {
    wp_die( __( 'Invalid request.' ) );
  }
}
add_filter( 'init', 'thistle_handle_privacy_confirm_action', 9 );

Avec ces quelques lignes, si quelqu’un arrive sur notre nouvelle URL sans faire une demande de confirmation sur une action RGPD valide, celui-ci sera en tête à tête avec un message d’erreur.

Après tout ça, il ne reste plus qu’à gérer l’essentiel : remplacer les URLs dans les emails envoyés par WordPress.

  1. l’URL de confirmation d’une action RGPD via le filtre user_request_action_email_content.
  2. l’URL de téléchargement de l’archive contenant nos données personnelles via le filtre user_request_action_email_content.
/**
 * Overrides the confirmation link into the email sent to the user
 * when an account action is attempted.
 *
 * @param string $email_text Text in the email.
 * @param array  $email_data {
 *     Data relating to the account action email.
 *
 *     @type WP_User_Request $request     User request object.
 *     @type string          $email       The email address this is being sent to.
 *     @type string          $description Description of the action being performed so the user knows what the email is for.
 *     @type string          $confirm_url The link to click on to confirm the account action.
 *     @type string          $sitename    The site name sending the mail.
 *     @type string          $siteurl     The site URL sending the mail.
 * }
 * @return string Text in the email.
 */
function thistle_override_privacy_confirm_url( $email_text, $email_data ) {
    $confirm_url_query = parse_url( $email_data['confirm_url'], PHP_URL_QUERY );
    $confirm_url = thistle_privacy_confirm_public_url() . '?' . $confirm_url_query;

    $email_text = str_replace( '###CONFIRM_URL###', esc_url_raw( $confirm_url ), $email_text );

    return $email_text;
}
add_filter( 'user_request_action_email_content', 'thistle_override_privacy_confirm_url', 10, 2 );

/**
 * Overrides the URL of the personal data export file into the email sent to the user.
 *
 * @param string $email_text Text in the email.
 * @param int    $request_id The request ID for this personal data export.
 * @return string
 */
function thistle_override_privacy_file_url( $email_text, $request_id ) {
    $export_file_url = get_post_meta( $request_id, '_export_file_url', true );
    $export_file_url = str_replace( wp_privacy_exports_url(), thistle_privacy_exports_public_url(), $export_file_url );

    $email_text = str_replace( '###LINK###', esc_url_raw( $export_file_url ), $email_text );

    return $email_text;
}
add_filter( 'wp_privacy_personal_data_email_content', 'thistle_override_privacy_file_url', 10, 2 );

Je pensais qu’avec ce ticket 44353 nous accèderions à une façon plus « simple » de procéder au remplacement de l’URL de confirmation mais non. Et les tickets 44376 et 44235 n’ont pas été plus chanceux…

Pour finir, si vous avez envie d’aller plus loin avec la partie RGPD de WordPress pour rajouter vos données personnalisées aux exports par exemple, pensez à https://developer.wordpress.org/plugins/privacy/.

Voila, c’est fini 😁

Améliorer le choix des dispositions dans un contenu flexible ACF

Je pense que tout est parti d’un screenshot que j’ai vu passer sur le slack WordPress-fr et où je me suis dit que ça serait génial d’avoir la même chose pour nos contributeurs 😍

Au boulot, nous adorons les contenus flexibles ACF mais il faut bien se l’avouer, la popin pour choisir les dispositions n’est pas très « sexy » pour l’utilisateur final.

Je pensais que cela serait assez simple car comme nous le savons tous, ACF est très extensible et bien codé, sauf qu’il a fallu réfléchir un peu plus que prévu 😅 
ACF ne propose pas encore la possibilité d’ajouter une option à une disposition d’un contenu flexible. Pour les curieux, ça se passe ici : plugins/advanced-custom-fields-pro/pro/fields/class-acf-field-flexible-content.php#L564-648 et vraiment aucun hook dans les parages… De toute façon, en y réfléchissant un peu, rajouter une image depuis l’interface qu’il faudrait ensuite stocker dans la médiathèque (alors que c’est déjà assez le bordel) ou ailleurs n’est pas une idée si simple que ça.

Alors regardons du côté de la popin qui affiche cette fameuse liste des dispositions :

<script type="text-html" class="tmpl-popup"><?php 
	?><div class="acf-fc-popup"><ul><?php foreach( $layouts as $layout ): 
		
		$atts = array(
			'href'			=> '#',
			'data-layout'	=> $layout['name'],
			'data-min' 		=> $layout['min'],
			'data-max' 		=> $layout['max'],
		);
		
		?><li><a <?php acf_esc_attr_e( $atts ); ?>><?php echo $layout['label']; ?></a></li><?php 
	
	endforeach; ?></ul></div>
</script>

Toujours pas de hook mais nous savons maintenant qu’il va falloir bidouiller le nom de nos dispositions 😂 Mais attention cette opération n’est pas si simple que ça. La documentation d’ACF acf/fields/flexible_content/layout_title nous dit que cette donnée $layout['label'] est aussi utilisée pour composer le titre principal pour nos dispositions…

Alors comment fait-on ?

Code

Dans un premier temps, nous amenons tout le luxe dont nous aurons besoin pour la personnalisation des dispositions de nos contenus flexibles. Nous pourrons donc choisir :

  • le répertoire qui contient les images pour tous les champs ou pour chaque champ individuellement ;
  • l’extension de l’ensemble des images (on ne mélange pas les formats) ;
  • l’URL spécifique pour une disposition en particulier.

Et nous disposerons de données supplémentaires sur chaque disposition comme l’url de l’image de remplacement et le titre.

/**
 * Improve client’s experience by providing placeholder images instead of basic texts
 * to choose layouts in flexible content fields.
 *
 * @param array $field The field settings array. This can be modified and then returned
 * @return array $field The field settings array
 */
function swp_acf_override_flexible_content_layout_labels( $field ) {
    if ( $field['type'] != 'flexible_content' || (isset( $field['layout'] ) && empty( $field['layout'] )) ) {
        return $field;
    }

    $placeholders_rel_path = apply_filters( "swp_acf/fields/flexible_content/placeholders_rel_path", '', $field );
    $placeholders_rel_path = apply_filters( "swp_acf/fields/flexible_content/placeholders_rel_path/name={$field['_name']}", $placeholders_rel_path, $field );
    $placeholders_rel_path = apply_filters( "swp_acf/fields/flexible_content/placeholders_rel_path/key={$field['key']}", $placeholders_rel_path, $field );
    $placeholders_rel_path = rtrim( $placeholders_rel_path, '/' );

    if ( $placeholders_rel_path == '' ) {
        return $field;
    }

    $placeholders_extension = apply_filters( "swp_acf/fields/flexible_content/placeholders_extension", 'jpg', $field );
    $placeholders_extension = trim( $placeholders_extension, '.  \t\n\r\0\x0B' );
    $theme_file_uri = get_theme_file_uri();

    foreach ( $field['layouts'] as $key => $layout ) {
        $old_label = $field['layouts'][ $key ]['label'];

        $palceholder_url = $theme_file_uri . '/' . $placeholders_rel_path . '/' . $layout['name'] . '.' . $placeholders_extension;
        $palceholder_url = apply_filters( "swp_acf/fields/flexible_content/placeholder_url/name={$field['_name']}", $palceholder_url, $layout['name'], $layout, $field );
        $palceholder_url = apply_filters( "swp_acf/fields/flexible_content/placeholder_url/key={$field['key']}", $palceholder_url, $layout['name'], $layout, $field );

        $field['layouts'][ $key ]['title'] = $old_label;
        $field['layouts'][ $key ]['placeholder'] = $palceholder_url;
        $field['layouts'][ $key ]['label'] = '<img src="' . $palceholder_url . '">';
    }

    return $field;

}
add_filter( 'acf/prepare_field/type=flexible_content', 'swp_acf_override_flexible_content_layout_labels', 9, 1 );

Sans risque, nous rendons son titre à la disposition si notre nouvelle donnée existe. Pas question d’aller afficher une image à la place du titre 😉

/**
 * Reset the text (HTML) displayed at the top of each flexible content layout
 * with the first textual label instead of keeping the image placeholder.
 *
 * @param string $title  The layout title text. Defaults to the layout title
 * @param array  $field  The flexible content field settings array
 * @param array  $layout The current layout settings array
 * @param int    $i      The current layout index. The first layout index is 0
 * @return string $title The layout title text
 */
function swp_acf_reset_flexible_content_layout_title( $title, $field, $layout, $i ) {
    if ( isset( $layout['title'] ) && ! empty( $layout['title'] ) ) {
        return $layout['title'];
    }

    return $title;
}
add_filter( 'acf/fields/flexible_content/layout_title', 'swp_acf_reset_flexible_content_layout_title', 9, 4 );

Et pour finir avec quelques styles « inline », nous adaptons l’affichage des nouveaux contenus de la popin pour qu’il colle au fonctionnement par défaut établi par ACF ☺️

/**
 * Add some inline style to correctly display layout's image placeholder 
 * and follow the default ACF behaviour in the flexible content field popup.
 *
 * @param void
 * @return void
 */
function swp_acf_add_flexible_content_layout_placeholder_styles() {
    $inline_styles = <<<CSS
.acf-fc-popup a.disabled {
  cursor: default;
  pointer-events: none;
}
.acf-fc-popup img {
  display: block;
  height: auto;
  margin: -6px -5px;
  max-width: 135px;
  width: auto;
}
.acf-fc-popup /*a*/.disabled img {
  opacity: .5;
  filter: grayscale(100%);
}
.acf-fc-popup a:hover {
  background: 0;
}
.acf-fc-popup a:hover img {
  outline: 6px solid #0073aa;
}
CSS;

    wp_add_inline_style( 'acf-input', $inline_styles );
}
add_action( 'admin_enqueue_scripts', 'swp_acf_add_flexible_content_layout_placeholder_styles', PHP_INT_MAX );

Usage

function tc_acf_set_font_page_placeholders_rel_path( $rel_path ) {
    return '/assets/media/placeholders/acf/front-page/';
}
add_filter( 'swp_acf/fields/flexible_content/placeholders_rel_path/key=field_5a83f8cc4b135', 'tc_acf_set_font_page_placeholders_rel_path' );

Voila, c’est fini 😁

Edit 6 juin 2018

En écrivant ce post, je n’avais pas connaissance de ressources similaires mais je viens d’en trouver deux en même temps 😄

Ma méthode aborde le problème différemment mais au final l’utilisateur sera ravi 😘

Remplacement “responsive” d’image en CSS

Comme nous le savons, la création graphique d’un site passe toujours souvent par la réalisation d’éléments inutilement visuellement complexes. Avec un peu de chance, voici sur quoi vous pourriez ou avez pu tomber en ouvrant un fichier :

Cette construction artistique se situant certainement dans un mouvement abstrait, futuriste ou art nouveau ne donne malheureusement pas la même satisfaction à toutes les étapes de la conception. Quoique.

Pour être franc, mon point de vue sur ce genre de choses (que je considère dénuées de tout bon sens) est bien arrêté : « Elles ne devront jamais être représentées par une structure HTML complexe et des centaines de lignes CSS imbuvables » .

Pour les autres, je vous laisse en tête à tête avec la citation de Kevin Rocher :

Laisse les donc prouver qu’ils peuvent se faire un croque-monsieur au micro-ondes.

Sur le papier

La demande paraît justifiée et réalisable. Cependant, une fois le story réalisé (pour l’occasion et par moi même), celui-ci a identifié quelques contraintes :

  • L’image d’arrière-plan doit pouvoir être dessinée dans une surface contenant le contenu de taille variable
  • L’image de remplacement ne doit pas être étirée ou déformée (le ratio original doit toujours être préservé)
  • Le ratio intrinsèque doit être calculé en fonction de la largeur de la surface contenant le contenu et non celle de son parent
  • Même si il nous serait facile d’utiliser une balise <img /> (cf : « Un reset “responsive” pour les images. »), il n’y a aucun intérêt à ce que cette information soit présente sous forme d’image si le style n’est pas appliqué (dans le code source de la page).

L’une des techniques les plus connues pour remplacement du texte par une image en CSS est certainement celle du text-indent: -9999px; pour laquelle il a été proposé une récente alternative tout à fait honorable mais qui a toutefois ses limites. Pour ceux qui aime les choses plus « .ir » , vous avez la solution proposée par Nicolas Gallagher (« Another CSS image replacement technique ») qui a elle aussi été remplacée sans trop de bruit par celle-ci dans le projet HTML5 Boilerplate.

Le fait est qu’aucune de ces solutions ne répond à mon besoin. Elles nécessitent toutes de définir une hauteur (et une largeur dans beaucoup de cas) à notre contenant pour afficher l’image de remplacement. En m’arrêtant là, je serai inévitablement entraîné (pour ne pas dire obligé d’aller) vers la mise en place de solutions spécifiques qui peuvent être démultipliées en fonction des cas.

Dans la pratique

.rwd-ir { 
  max-width: 308px; /* Natural width */ 
  background-image: url(le-client-et-le-graphiste-ont-toujours-raison.png);        
  background-size: 100%; font: 0/0 a; 
}
.rwd-ir:before { 
  display: block; 
  padding: 32.4675% 0 0; /* Height in percent (Natural height: 100px) */ content: ""; 
}

Ça se passe plutôt bien. Ça ne paraît pas !

Que font chaques déclarations ?

  • max-width: 308px; – Permet de s’assurer que la largeur naturelle de l’image ne sera jamais dépassée.
  • background-size: 100%; – Spécifie que l’image d’arrière-plan doit être mise à l’échelle pour être aussi grande que possible sur la largeur de la zone de contenu. Dans le cas où une/des marges intérieures seraient appliquées, les déclarations background-origin: content-box; et background-repeat: no-repeat; devront être ajoutées pour que le fond soit peint uniquement dans la zone de contenu et éviter la répétition.
  • .rwd-ir:before {} – Donne à la surface contenant le contenu un format spécifique qui respecte le ratio de l’image de remplacement et cela par rapport à sa propre largeur. Cette méthode est inspirée de celle utilisée dans SUIT flexible embed ou FitVids.js pour ne citer qu’eux. À la seule différence que je profite d’un pseudo-élément.

Ce qu’il faut savoir

Aucun hack de remplacement n’est parfait. Celui au dessus a lui aussi ses limites (même s’il a plutôt bien marché lors de mon dernier projet) :

  • L’utilisation de la propriété background-size impose ses limites dans IE 8. Dans les cas simples, un polyfill suffira. Dans les plus complexes, dans les plus complexes…
  • Cette approche exclut complètement le support d’IE 7. Je n’ai pas de regrets à cela car elle n’a pas été pensée pour.
  • Comme beaucoup de méthodes de remplacement, elles ne fonctionnent pas si les CSS sont chargées mais que les images ne le sont pas. Que voulez-vous, nous ne pouvons pas tout avoir.

Pour finir

Les possibilités pour résoudre ce problème sont à coup sûr infinies mais en restant raisonnable (et en sachant ce que vous faites) cette technique s’utilisera sans encombre.

outline – une spec approximative

Honorable défenseur de la propriété CSS outline (du moins jusque là), j’ai toujours pensé/prétendu que celle-ci était sous-estimée. Afin appuyer cette affirmation contestable, voici deux utilisations qui ne manquent pas d’imagination :

Brievement, la propriété outline a été établie lors de la spécification CSS2 et n’a que très peu évolué depuis. Le seul gros changement est amené par la propriété complémentaire outline-offset dans le niveau 3 de CSS (source : CSS3 UI). Je vous l’accorde, il n’y a rien de bien transcendant surtout quand on ne la voit même pas apparaître dans la notation raccourcie outline

Nous pourrions débattre longuement sur le fonctionnement qui se veut très minimaliste (sans réelles explications) mais ce n’est pas le sujet.

Dans la pratique

Nous utilisons que très rarement cette propriété. A tort certainement. L’usage le plus courant et le plus sauvage/horrible/désespérant reste : * { outline: none; }. Dans mon cas, j’ai pris l’initiative de profiter de cette propriété pour remplacer/simuler une bordure.

Pourquoi ?

  1. Une envie est une envie. Ne pas y céder ne serait que plus frustrant.
  2. Un graphiste a prend le droit de faire des bordures externes.
  3. L’intégrateur râle mais n’a pas le choix parce qu’il le veut bien…
  4. Le support de box-shadow commence qu’à partir de IE9 (source : caniuse.com)

A première vue, ça semble génial. Mais ne nous y trompons pas, le web va rarement dans le même sens (ça serait trop facile). Cette fois, c’est Opera qui ne semble/veut pas avoir la même vision des choses : table des rendustable des rendus mobiles

Un grand nombre (des développeurs pour la plupart) diront : “Opera bla bla bla, les stats sur nos serveurs bla bla bla”. Je ne suis pas de cet avis, Opera représente aujourd’hui 300 millions d’utilisateurs qui vont prochainement devoir faire avec Webkit mais qui restent des utilisateurs dont nous devons nous préoccuper.

Et alors ?

Il y a peu de ressources qui relatent le sujet :

Le bug problème est connu et cela depuis la version 9.5 d’Opera. Difficile d’en dire plus (sans se tromper) car jusque là je n’ai pas trouvé de billet/ticket rapportant le problème officiellement.

La première ressource est certainement la plus intéressante. Pour ne pas changer, la vérité vraie se trouve dans les documentations (CSS 2.1CSS3). Et voila de quoi nous laisser dubitatif :

The outline created with the outline properties is drawn “over” a box, i.e., the outline is always on top, and does not influence the position or size of the box, or of any other boxes.

This specification does not define how multiple overlapping outlines are drawn, or how outlines are drawn for boxes that are partially obscured behind other elements.

Since the outline does not affect formatting (i.e., no space is left for it in the box model), it may well overlap other elements on the page.

Difficile alors de parler de BUG après ces trois extraits. Chacun est libre d’y voir ce que bon lui semble.

Cependant, il est peut être exagérer de garder un tel contre pied à travers 11 versions de navigateur et 9 versions de moteur (source : Wikipédia). Malheureusement, ce ne sera pas la dernière fois que ce genre de choses se passe surtout quand tout le monde à les yeux tournés vers les nouveautés CSS3.

Mais après tout, pourquoi Opéra n’aurait-il pas raison sur son implémentation de la propriété outline ?

Faisons attention, une prochaine version de WebKit pourrait avoir droit à cette interprétation.