Skip to content

Mise en place d’offres d’emploi

Objectif

Créer un nouveau type de contenu “Offre d’emploi” avec une page d’archive dédiée, en suivant la méthodologie recommandée par Theme32Blank, et inclure des champs personnalisés à afficher à la fois sur une tuile et sur une page spécifiques à ce type de contenu.

Chapitre

  1. Création d’un nouveau type de contenu et de sa page d’archive
  2. Ajout de champs personnalisés
  3. Création du modèle de page dédié à l’affichage d’une offre d’emploi.
  4. Création d’une tuile spécifique au type de contenu avec récupération des champs personnalisés.

Introduction

La mise en place de ces éléments est grandement facilité par le Theme32Blank et les commandes qui lui sont associées.

Une seule commande va nous permettre de générer le type de contenu personnalisé, les fichiers pour gérer la page d’archive personnalisée ainsi que la mise à jour des configurations.

Il faudra seulement intervenir dans le code pour personnaliser l’affichage.

Génération du nouveau type de contenu

Depuis le conteneur PHP de votre projet faites :

bash
wp acorn t32b:posttype job

Dans les questions qui suivent, répondez y à la question Has archive ?.

C’est terminé 🔥. La commande détaille en fin d’execution l’ensembles des modifications apportées dans votre thème.

Liaison de la page BO avec la configuration

Depuis votre BO, créez une nouvelle page Les offres d'emploi, puis liez cette page dans la configuration de votre thème.

Désactiver l’éditeur gutenberg

Dans la plupart des cas, nous ne voulons pas que l’éditeur Gutenberg soit visible en BO pour les pages d’archive (bien sûr, si vous l’utilisez dans le fichier archive-[post_type_key].blade.php, vous devez le laisser visible).

Toujours dans le fichier app\Providers\ThemeServiceProvider.php, modifiez l’option app.options.without_editor_page comme suit :

diff
...
public function register(): void
{
    $config = $this->app['config'];

    if (function_exists('pll_default_language') && function_exists('pll_current_language')) {
        $config->set('app.options.default_language', $default_language = pll_default_language());
        $config->set('app.options.current_language', $current_language = pll_current_language());
        $config->set('app.options.key', 'options'.($current_language !== false && $default_language != $current_language ? '-'.$current_language : ''));
    } else {
        $config->set('app.options.key', 'options');
    }

    $config->set('app.options', [
        ...$config['app']['options'],
        'page_home' => (int) (get_option('page_on_front') ?: 0),
        'archive_post' => (int) (get_option(config('app.options.key').'_t32b_theme_custom_pages_archive_post') ?: 0),
        'archive_cpt_blank' => (int) (get_option(config('app.options.key').'_t32b_theme_custom_pages_archive_cpt-blank') ?: 0),
        'page_contact' => (int) (get_option(config('app.options.key').'_t32b_theme_custom_pages_page_contact') ?: 0),
        'page_privacy_policy' => (int) (get_option('wp_page_for_privacy_policy') ?: 0),
        'page_legal_notice' => (int) (get_option(config('app.options.key').'_t32b_theme_custom_pages_page_legal-notice') ?: 0),
        'archive_job' => (int) (get_option(config('app.options.key').'_t32b_theme_custom_pages_archive_job') ?: 0),
    ]);

    $config->set('app.options.without_editor_page', [
        config('app.options.page_home'),
        config('app.options.archive_cpt_blank'),
+       config('app.options.archive_job'),
    ]);
}
...

Pour aller plus loin

Il est toujours agréable d’avoir une administration claire et intuitive, c’est pourquoi il est conseillé d’ajouter des post_stateau page du BO afin qu’on identifie facilement le rôle de chaques pages.

Dans le fichier app\admin.php vous pouvez modifier le hook display_post_statesafin que votre page d’archive affiche “Archive des offres d’emploi

diff
/*
 * Permet d'ajouter des states à certaines pages en BO
 */
add_filter(
    'display_post_states',
    /**
     * @return mixed
     *
     * @throws ContainerExceptionInterface
     * @throws NotFoundExceptionInterface
     */
    function ($post_states, $post) {
        $postStates = [
            config('app.options.page_contact') => __('Page de contact', config('app.domain')),
            config('app.options.page_legal_notice') => __('Page de mentions légales', config('app.domain')),
            config('app.options.archive_cpt_blank') => __('Archive des CPT Blank', config('app.domain')),
            config('app.options.archive_post') => __('Archive des articles', config('app.domain')),
+           config('app.options.archive_job') => __('Archive des offre d\'emploi', config('app.domain')),        
        ];

        foreach ($postStates as $key => $postState) {
            $postId = defined($key) ? constant($key) : (int) $key;

            if ($postId === $post->ID) {
                $post_states[] = $postState;
            }
        }

        return $post_states;
    }, 10, 2);

Pour tester la bonne implémentation de votre page d’archive, exécutez cette commande pour ajouter des offres d’emploi de test

bash
wp eval '
$jobs = [
    ["title"=>"Développeur WordPress","content"=>"Nous recherchons un développeur WordPress expérimenté pour rejoindre notre équipe.","status"=>"publish"],
    ["title"=>"Chef de projet digital","content"=>"Gestion de projets digitaux et coordination des équipes.","status"=>"publish"],
    ["title"=>"UX Designer","content"=>"Création et amélioration des expériences utilisateurs sur nos produits.","status"=>"publish"]
];

foreach($jobs as $job){
    $post_data = [
        "post_title"=>$job["title"],
        "post_content"=>$job["content"],
        "post_status"=>$job["status"],
        "post_type"=>"job"
    ];
    $result = wp_insert_post($post_data, true);
    echo is_wp_error($result) ? "Erreur création '{$job["title"]}': ".$result->get_error_message()."\n" : "Job créé avec ID $result\n";
}
'

Ajout de champ ACF personnalisé

Dans le Theme32Blank, une commande est disponible pour généré des champs ACF. Depuis votre container php exécutez cette commande :

bash
wp acorn acf:field JobField

Modifier la méthode fieldsdu fichier nouvellement créé :

php
use App\Configurations\Fields\Partials\PageHeaderPartial;
...
/**
 * The field group.
 */
public function fields(): array
{
    $fields = Builder::make('t32b_job_field', [
        'title' => __('Configuration de la page', config('app.domain')),
    ]);

    $fields
        ->setLocation('post_type', '==', 'job');

    $fields->addPartial(PageHeaderPartial::class);

    $sideFields = Builder::make('t32b_job_side_field', [
        'title' => __('Configuration de l\'offre d\'emploi', config('app.domain')),
        'position' => 'side'
    ]);

    $sideFields
        ->setLocation('post_type', '==', 'job');

    $sideFields->addRadio('contract_type', [
        'label' => __('Type de contrat', config('app.domain')),
        'choices' => [
            'CDI' => __('CDI', config('app.domain')),
            'CDD' => __('CDD', config('app.domain')),
            'Stage' => __('Stage', config('app.domain')),
            'Alternance' => __('Alternance', config('app.domain')),
        ],
        'return_format' => 'label'
    ])->addRadio('location', [
        'label' => __('Lieu', config('app.domain')),
        'choices' => [
            'saintetienne' => __('Saint-Étienne', config('app.domain')),
            'lepuyenvelay' => __('Le Puy-en-Velay', config('app.domain')),
            'lyon' => __('Lyon', config('app.domain')),
        ],
        'return_format' => 'label'
    ]);

    return [$fields->build(), $sideFields->build()];
}
...

Renseignez des valeurs aux différentes offres d’emploi de test.

Création du modèle de page dédié au type de contenu

Dans ce chapitre nous allons créer une page dédié au type de contenu et voir comment récupérer les champs associés.

Créez un fichier single-job.blade.phpdans votre thème avec le contenu suivant :

php
@extends('layouts.app')

@section('content')
    @include('partials.post-header')

    <hr class="wp-block-separator alignwide">
    
    <ul>
        @foreach($metas as $meta)
            <li>
                <x-badge :label="$meta"/>
            </li>
        @endforeach
    </ul>
    
    <hr class="wp-block-separator alignwide">
    
    @if(! $content->isEmpty())
        {!! $content !!}
    @endif
@endsection

Nous avons besoin de deux variables : $contentet $metas

Dans le Theme32Blank, la récupération des champs ACF d’un type de contenu personnalisé est automatique grâce à la classe BaseModel

Il va donc simplement falloir instancier cette classe dans un ViewComposer associé à notre modèle.

Création du ViewComposer

bash
wp acorn make:composer JobComposer

Ajoutez single-jobdans la liste des views

Comme évoqué juste avant, le Theme32Blank récupère automatiquement les champs ACF via la classe BaseModel mais si vous souhaitez avoir de l’auto-complétion, il va falloir créer un Modelpour le type de contenu Job.

Créer un dossier app\Modelset ajoutez une classe JobModel avec le contenu suivant :

php
<?php

namespace App\Models;

use App\Support\Models\BaseModel;

/**
 * @property string $contract_type
 * @property string $location
 */
class JobModel extends BaseModel
{}

Cette classe peut rester vide et simplement ajouter des propriétés via la notation @phpDoc mais si vous avez besoin de faire du traitement sur les champs récupérés (formatage, calcul, etc…) vous pourrez ajouter directement des méthodes ici.

L’avantage maintenant c’est qu’il vous suffit d’instancier JobModelau lieu de BaseModeldans vos différentes classes pour que l’auto-complétion trouve les propriétés contract_type et location

Modification de la classe Queryassocié pour qu’elle utilise JobModel

diff
<?php

namespace App\Repositories\Queries;

- use App\Support\Models\BaseModel;
+ use App\Models\JobModel;
use App\Support\Query;
use App\View\Data\ResultsPaginatedData;

class JobQueryRepository
{
    protected string|array $postTypes = 'job';

    public function __construct(protected Query $query)
    {
        $this->query->updateQuery('post_type', $this->postTypes);
    }
    
-   public function current(): BaseModel
-   {
-       return $this->findOne();
-   }
-
-   public function findOne(?int $id = null): BaseModel
-   {
-       return new BaseModel($id);
-   }

+   public function current(): JobModel
+   {
+       return $this->findOne();
+   }
+
+   public function findOne(?int $id = null): JobModel
+   {
+       return new JobModel($id);
+   }

    public function findAllPaginated(array $excludeIds = []): ResultsPaginatedData
    {
        $currentPage = get_query_var('paged') ?: 1;
        $wpQuery = $this->query
            ->updateQuery('paged', $currentPage)
            ->excludeByIds($excludeIds)
            ->getWpQuery();

        return new ResultsPaginatedData(
-           items: collect($wpQuery->posts)->map(fn ($post) => new BaseModel($post->ID))->toArray(), 
+           items: collect($wpQuery->posts)->map(fn ($post) => new JobModel($post->ID))->toArray(),
            totalCount: $wpQuery->found_posts,
            perPage: $wpQuery->query_vars['posts_per_page'],
            currentPage: $currentPage
        );
    }
}

Transmission des données

Dans la classe JobComposer , ajoutez le code suivant :

diff
<?php

namespace App\View\Composers;

+ use App\Repositories\Queries\JobQueryRepository;
use Roots\Acorn\View\Composer;

class JobComposer extends Composer
{
    /**
     * List of views served by this composer.
     *
     * @var string[]
     */
    protected static $views = [
        'single-job',
    ];

+   public function __construct(public JobQueryRepository $query) {}

+   public function with(): array
+   {
+       $job = $this->query->current();
+       
+       return [
+           'content' => $job->content(),
+           'metas' => array_filter( // On clean les valeurs vide, false ou null
+               [
+                   $job->contract_type,
+                   $job->location,
+               ]
+           ),
+       ];
+  }

}

Voila vous savez à présent, récupérer des données et les envoyer à une vue de manière propre, prévisible et reproductible.

Création d’une tuile dédiée

Maintenant que les données remontent sur le modèle de page, nous allons créer une tuile et faire la remonter des informations à l’intérieur.

Là, encore le Theme32Blank (v2.0.3) fourni une commande pour générer les classes de gestions des cards :

bash
wp acorn t32b:card job

Vous pouvez spécifier un model personnalisé si vous en avez crée. C’est justement le cas ici : renseignez simplement JobModel à la place BaseModel

Comme vous le verrez dans le retour de la commande, plusieurs classes sont créée.

app/Mappers/CardJobMapper.php, app/View/Data/CardJobData.php , app/View/Components/CardJob.php et le fichier de vue resources/views/components/card-job.blade.php

Nous allons faire les modifications nécessaire pour que les données des offres d’emploi remontent correctement

Utilisation de CardJobDatadans le component

Première chose à faire est de passer un objet de type CardJobData à notre composant CardJob

diff
<?php

namespace App\View\Components;

+ use App\View\Data\CardJobData;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;

class CardJob extends Component
{
    /**
     * Create a new component instance.
     */
-   public function __construct()
+   public function __construct(public CardJobData $card)
    {
        //
    }

    /**
     * Get the view / contents that represent the component.
     */
    public function render(): View|Closure|string
    {
        return view('components.card-job');
    }
}

Ensuite nous allons modifier notre vues afin de visualiser les éléments manquants. Comme nous souhaitons créer une card, pour cette exemple, nous allons simplement récupérer le contenu du composant card et lui ajouter les champs spécifiques aux offres d’emploi.

php
@php /** @var \App\View\Data\CardJobData $card */ @endphp

<div class="@container">
    <div {{$attributes->class(['card'])}}>
        <div class="card__body">
            <div class="card__content">
                <div class="card__title !mt-0">
                    @isset($title)
                        {!! $title !!}
                    @else
                        {!! $card->title !!}
                    @endisset
                </div>
                @if(!empty($card->excerpt))
                    <p>{!! $card->excerpt !!}</p>
                @endif
                <ul>
                    @foreach($card->metas as $meta)
                        <li><x-badge :label="$meta"/></li>
                    @endforeach
                </ul>
            </div>
            <div class="wp-block-button is-style-link">
                <x-link :link="$card->button" class="wp-element-button stretched-link"/>
            </div>
        </div>
        @if(! $card->image->isNull())
            <div class="card__thumbnail">
                <img src="{{$card->image->url}}" alt="{{$card->image->alt}}" loading="lazy"
                     width="{{$card->image->width}}" height="{{$card->image->height}}">
            </div>
        @endif
    </div>
</div>

Grâce à cette vue, nous connaissons exactement nos besoins : un objet cardqui contient les propriétés suivante :

  • $card->title : une chaine de caractère
  • $card->excerpt : une chaine de caractère
  • $card->metas : un tableau de chaine de caractère
  • $card->button : un objet de type Link
  • $card->image : un objet de type Image

Regardons maintenant nos classes CardJobData afin d’identifier les éléments manquants/présents

php
<?php

namespace App\View\Data;

use App\Support\Types\Image;
use App\Support\Types\Link;

readonly class CardJobData
{
    public function __construct(
        public string $title,
        public string $excerpt,
        public Image $image,
        public Link $button,
    ) {}

    public function isNull(): bool
    {
        return $this->title === '';
    }
}

Seul les metassont manquants, ajoutons-les :

diff
<?php

namespace App\View\Data;

use App\Support\Types\Image;
use App\Support\Types\Link;

readonly class CardJobData
{
    public function __construct(
        public string $title,
        public string $excerpt,
+       public array $metas,
        public Image $image,
        public Link $button,
    ) {}

    public function isNull(): bool
    {
        return $this->title === '';
    }
}

Nous devons dans ce cas modifier la classe CardJobMapperafin qu’elle prennent en charges cette nouvelle propriété :

diff
<?php

namespace App\Mappers;

use App\Models\JobModel;
use App\Support\Types\Image;
use App\Support\Types\Link;
use App\View\Data\CardJobData;

class CardJobMapper
{
    public function fromModel(JobModel $post): CardJobData
    {
        return new CardJobData(
            title: $post->title(),
            excerpt: $post->excerpt(),
+           metas: array_filter([
+             $post->contract_type,
+             $post->location
+           ]),
            image: $post->thumbnail(),
            button: new Link(
                url: $post->url(),
                target: '',
                title: __('En savoir plus', config('app.domain'))
            )
        );
    }

    public function fromId(int $id): CardJobData
    {
        return $this->fromModel(new JobModel($id));
    }

    public function fake(): CardJobData
    {
        return new CardJobData(
            title: __("Tuile d'exemple", config('app.domain')),
            excerpt: '',
+           metas: [
+            'CDD',
+            'Lyon'
+           ],
            image: Image::default(),
            button: Link::fromArray(['title' => __('En savoir plus', config('app.domain'))]),
        );
    }

    public function null(): CardJobData
    {
        return new CardJobData(
            title: '',
            excerpt: '',
+           metas: [],
            image: Image::null(),
            button: Link::null(),
        );
    }
}

Maintenant que nous avons un nouveau composant, il faut transmettre les bonnes classes à nos vues pour que ce soit les bonnes données qui remontent.

Modifiez le ViewComposer ArchiveJobComposer :

diff
<?php

namespace App\View\Composers;

+ use App\Mappers\CardJobMapper;
- use App\Mappers\CardMapper;
use App\Repositories\Queries\JobQueryRepository;
use App\Support\Concerns\WordpressPagination;
use Roots\Acorn\View\Composer;

class ArchiveJobComposer extends Composer
{
    use WordpressPagination;

    /**
     * List of views served by this composer.
     *
     * @var string[]
     */
    protected static $views = [
        'archive-job',
    ];

-   public function __construct(public JobQueryRepository $queryRepository, public CardMapper $cardMapper) {}
+   public function __construct(public JobQueryRepository $queryRepository, public CardJobMapper $cardMapper) {}

    public function with(): array
    {
        $results = $this->queryRepository->findAllPaginated();

        return [
            'cards' => array_map(fn ($model) => $this->cardMapper->fromModel($model), $results->items),
            'pagination' => $this->renderPagination($results->totalPages()),
            'prevLink' => $this->prevLink(),
            'nextLink' => $this->nextLink($results->totalPages()),
        ];
    }
}

Il ne nous reste plus qu’a modifier le composant utilisé dans notre modèle d’archive :

diff
@extends('layouts.app')

@section('content')

    @include('partials.page-header')

    @if(! empty($cards))
        <div>
            <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
                @foreach($cards as $card)
-                   <x-card :$card />
+                   <x-card-job :$card />
                @endforeach
            </div>

            <x-pagination :content="$pagination" :nextButton="$nextLink">
                @if(! empty($prevLink))
                    <x-slot:prevButton>
                        <a href="{{$prevLink}}">
                            <x-pagination.prev :label="__('Précédent', config('app.domain'))"/>
                        </a>
                    </x-slot:prevButton>
                @endif
                @if(! empty($nextLink))
                    <x-slot:nextButton>
                        <a href="{{$nextLink}}">
                            <x-pagination.next :label="__('Suivant', config('app.domain'))"/>
                        </a>
                    </x-slot:nextButton>
                @endif
            </x-pagination>

        </div>
    @endif
@endsection

Conclusion

👏👏👏 C’est terminé !

Nous avons vu ensemble comment créer toutes les briques structurelles d’un type de contenu personnalisé dans le Theme32Blank en exploitant au maximum ses capacités.