Aller au contenu principal

Renderers Personnalisés

Les renderers personnalisés permettent d'étendre les capacités d'édition inline du SigmasoftDataTableBundle en créant vos propres types de champs éditables.

Architecture des Renderers

Le système de renderers utilise le Strategy Pattern pour permettre une extensibilité maximale :

FieldRendererInterface
├── AbstractFieldRenderer
│ ├── TextFieldRenderer
│ ├── SelectFieldRenderer
│ ├── TextAreaFieldRenderer
│ ├── ColorFieldRenderer
│ └── VotreRendererPersonnalise
└── FieldRendererRegistry

Création d'un Renderer Personnalisé

1. Implémenter l'interface

<?php

namespace App\DataTable\Renderer;

use Sigmasoft\DataTableBundle\InlineEdit\Renderer\FieldRendererInterface;
use Sigmasoft\DataTableBundle\InlineEdit\Configuration\EditableFieldConfiguration;

class DatePickerFieldRenderer implements FieldRendererInterface
{
public function supports(EditableFieldConfiguration $config): bool
{
return $config->getFieldType() === 'datepicker';
}

public function render(
EditableFieldConfiguration $config,
mixed $value,
object $entity,
string $fieldName
): string {
$attributes = $this->buildAttributes($config, $entity, $fieldName);
$formattedValue = $value instanceof \DateTimeInterface
? $value->format('Y-m-d')
: '';

return sprintf(
'<input type="date" class="form-control datepicker-field"
data-entity-id="%s"
data-field-name="%s"
value="%s" %s>',
$entity->getId(),
$fieldName,
htmlspecialchars($formattedValue),
$attributes
);
}

public function renderStatic(
EditableFieldConfiguration $config,
mixed $value,
object $entity
): string {
if ($value instanceof \DateTimeInterface) {
return $value->format('d/m/Y');
}
return '-';
}

public function validateValue(mixed $value, EditableFieldConfiguration $config): array
{
$errors = [];

if ($config->isRequired() && empty($value)) {
$errors[] = 'Ce champ est requis';
}

if (!empty($value)) {
$date = \DateTime::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
$errors[] = 'Format de date invalide';
}
}

return $errors;
}

public function normalizeValue(mixed $value, EditableFieldConfiguration $config): mixed
{
if (empty($value)) {
return null;
}

return \DateTime::createFromFormat('Y-m-d', $value);
}

private function buildAttributes(
EditableFieldConfiguration $config,
object $entity,
string $fieldName
): string {
$attributes = [];

// Attributs de validation
if ($config->isRequired()) {
$attributes[] = 'required';
}

// Attributs data personnalisés
foreach ($config->getDataAttributes() as $key => $value) {
$attributes[] = sprintf('data-%s="%s"', $key, htmlspecialchars($value));
}

// Min/Max dates si configurées
if ($minDate = $config->getOption('min_date')) {
$attributes[] = sprintf('min="%s"', $minDate);
}

if ($maxDate = $config->getOption('max_date')) {
$attributes[] = sprintf('max="%s"', $maxDate);
}

return implode(' ', $attributes);
}
}

2. Étendre AbstractFieldRenderer

Pour simplifier l'implémentation, étendez AbstractFieldRenderer :

<?php

namespace App\DataTable\Renderer;

use Sigmasoft\DataTableBundle\InlineEdit\Renderer\AbstractFieldRenderer;
use Sigmasoft\DataTableBundle\InlineEdit\Configuration\EditableFieldConfiguration;

class RatingFieldRenderer extends AbstractFieldRenderer
{
public function supports(EditableFieldConfiguration $config): bool
{
return $config->getFieldType() === 'rating';
}

protected function doRender(
EditableFieldConfiguration $config,
mixed $value,
object $entity,
string $fieldName
): string {
$maxStars = $config->getOption('max_stars', 5);
$currentRating = (int) $value;

$html = '<div class="rating-field" data-entity-id="' . $entity->getId() . '"
data-field-name="' . $fieldName . '"
data-current-value="' . $currentRating . '">';

for ($i = 1; $i <= $maxStars; $i++) {
$class = $i <= $currentRating ? 'star-filled' : 'star-empty';
$html .= sprintf(
'<span class="star %s" data-rating="%d">★</span>',
$class,
$i
);
}

$html .= '</div>';

return $html;
}

public function renderStatic(
EditableFieldConfiguration $config,
mixed $value,
object $entity
): string {
$maxStars = $config->getOption('max_stars', 5);
$rating = (int) $value;

return str_repeat('★', $rating) . str_repeat('☆', $maxStars - $rating);
}

public function validateValue(mixed $value, EditableFieldConfiguration $config): array
{
$errors = parent::validateValue($value, $config);

$maxStars = $config->getOption('max_stars', 5);
$rating = (int) $value;

if ($rating < 0 || $rating > $maxStars) {
$errors[] = sprintf('La note doit être entre 0 et %d', $maxStars);
}

return $errors;
}
}

3. Enregistrer le Renderer

Les renderers sont automatiquement enregistrés grâce au CompilerPass. Il suffit de taguer votre service :

# config/services.yaml
services:
App\DataTable\Renderer\DatePickerFieldRenderer:
tags:
- { name: sigmasoft.field_renderer }

App\DataTable\Renderer\RatingFieldRenderer:
tags:
- { name: sigmasoft.field_renderer }

Ou avec l'auto-configuration :

# config/services.yaml
services:
_instanceof:
Sigmasoft\DataTableBundle\InlineEdit\Renderer\FieldRendererInterface:
tags: ['sigmasoft.field_renderer']

Utilisation du Renderer

Dans le contrôleur

use Sigmasoft\DataTableBundle\InlineEdit\Configuration\EditableFieldConfiguration;

// Configuration pour le DatePicker
$dateConfig = EditableFieldConfiguration::create('datepicker')
->required(true)
->options([
'min_date' => '2020-01-01',
'max_date' => '2025-12-31'
]);

$column = $editableColumnFactory->create(
'delivery_date',
'deliveryDate',
'Date de livraison',
$dateConfig
);

// Configuration pour le Rating
$ratingConfig = EditableFieldConfiguration::create('rating')
->options(['max_stars' => 10])
->validationRules(['min' => 0, 'max' => 10]);

$ratingColumn = $editableColumnFactory->create(
'customer_rating',
'customerRating',
'Note client',
$ratingConfig
);

JavaScript associé

// assets/js/renderers/rating-field.js
document.addEventListener('DOMContentLoaded', function() {
// Gestion des clics sur les étoiles
document.addEventListener('click', function(e) {
if (e.target.classList.contains('star')) {
const rating = e.target.dataset.rating;
const container = e.target.closest('.rating-field');
const entityId = container.dataset.entityId;
const fieldName = container.dataset.fieldName;

// Mettre à jour l'affichage
updateStarDisplay(container, rating);

// Sauvegarder via l'API
saveRating(entityId, fieldName, rating);
}
});

function updateStarDisplay(container, rating) {
const stars = container.querySelectorAll('.star');
stars.forEach((star, index) => {
if (index < rating) {
star.classList.add('star-filled');
star.classList.remove('star-empty');
} else {
star.classList.remove('star-filled');
star.classList.add('star-empty');
}
});
}

function saveRating(entityId, fieldName, value) {
// Utiliser l'API d'édition inline
fetch(`/inline-edit/${entityId}/${fieldName}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ value: value })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Note enregistrée', 'success');
} else {
showNotification('Erreur: ' + data.message, 'error');
}
});
}
});

Styles CSS

/* assets/css/renderers/rating-field.css */
.rating-field {
display: inline-flex;
gap: 5px;
font-size: 24px;
}

.star {
cursor: pointer;
transition: color 0.2s ease;
user-select: none;
}

.star:hover {
color: #ffd700;
transform: scale(1.1);
}

.star-filled {
color: #ffd700;
}

.star-empty {
color: #ddd;
}

/* Animation de sauvegarde */
.rating-field.saving {
opacity: 0.6;
pointer-events: none;
}

.rating-field.saving::after {
content: '⌛';
margin-left: 10px;
animation: spin 1s linear infinite;
}

@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

Renderer avec Composant Complexe

Pour des composants plus complexes, vous pouvez utiliser des templates Twig :

<?php

namespace App\DataTable\Renderer;

use Sigmasoft\DataTableBundle\InlineEdit\Renderer\AbstractFieldRenderer;
use Twig\Environment;

class ImageUploadFieldRenderer extends AbstractFieldRenderer
{
public function __construct(
private Environment $twig
) {}

public function supports(EditableFieldConfiguration $config): bool
{
return $config->getFieldType() === 'image_upload';
}

protected function doRender(
EditableFieldConfiguration $config,
mixed $value,
object $entity,
string $fieldName
): string {
return $this->twig->render('renderers/image_upload.html.twig', [
'entity' => $entity,
'field_name' => $fieldName,
'current_image' => $value,
'config' => $config,
'allowed_extensions' => $config->getOption('allowed_extensions', ['jpg', 'png', 'gif']),
'max_size' => $config->getOption('max_size', 5 * 1024 * 1024) // 5MB
]);
}
}

Template Twig associé :

{# templates/renderers/image_upload.html.twig #}
<div class="image-upload-field"
data-entity-id="{{ entity.id }}"
data-field-name="{{ field_name }}">

{% if current_image %}
<div class="current-image">
<img src="{{ asset(current_image) }}" alt="Image actuelle" class="img-thumbnail">
<button type="button" class="btn btn-sm btn-danger remove-image">
<i class="bi bi-trash"></i>
</button>
</div>
{% endif %}

<div class="upload-zone">
<input type="file"
class="d-none image-input"
accept="{{ allowed_extensions|map(ext => '.' ~ ext)|join(',') }}"
data-max-size="{{ max_size }}">

<button type="button" class="btn btn-primary btn-sm upload-trigger">
<i class="bi bi-upload"></i> Choisir une image
</button>

<div class="upload-progress d-none">
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
</div>
</div>
</div>

<small class="text-muted">
Formats acceptés : {{ allowed_extensions|join(', ') }} |
Taille max : {{ (max_size / 1024 / 1024)|number_format(1) }} MB
</small>
</div>

Tests des Renderers

<?php

namespace App\Tests\DataTable\Renderer;

use App\DataTable\Renderer\RatingFieldRenderer;
use App\Entity\Product;
use PHPUnit\Framework\TestCase;
use Sigmasoft\DataTableBundle\InlineEdit\Configuration\EditableFieldConfiguration;

class RatingFieldRendererTest extends TestCase
{
private RatingFieldRenderer $renderer;

protected function setUp(): void
{
$this->renderer = new RatingFieldRenderer();
}

public function testSupports(): void
{
$config = EditableFieldConfiguration::create('rating');
$this->assertTrue($this->renderer->supports($config));

$config = EditableFieldConfiguration::create('text');
$this->assertFalse($this->renderer->supports($config));
}

public function testRender(): void
{
$config = EditableFieldConfiguration::create('rating')
->options(['max_stars' => 5]);

$product = new Product();
$product->setId(123);

$html = $this->renderer->render($config, 3, $product, 'rating');

$this->assertStringContainsString('data-entity-id="123"', $html);
$this->assertStringContainsString('data-field-name="rating"', $html);
$this->assertStringContainsString('data-current-value="3"', $html);
$this->assertEquals(3, substr_count($html, 'star-filled'));
$this->assertEquals(2, substr_count($html, 'star-empty'));
}

public function testValidation(): void
{
$config = EditableFieldConfiguration::create('rating')
->options(['max_stars' => 5])
->required(true);

// Valeur valide
$errors = $this->renderer->validateValue(3, $config);
$this->assertEmpty($errors);

// Valeur trop élevée
$errors = $this->renderer->validateValue(6, $config);
$this->assertContains('La note doit être entre 0 et 5', $errors);

// Valeur requise manquante
$errors = $this->renderer->validateValue('', $config);
$this->assertContains('Ce champ est requis', $errors);
}
}

Bonnes Pratiques

1. Validation robuste

Toujours valider côté serveur ET côté client :

public function validateValue(mixed $value, EditableFieldConfiguration $config): array
{
$errors = [];

// Validation de base
if ($config->isRequired() && empty($value)) {
$errors[] = 'Ce champ est requis';
}

// Validation spécifique au type
// ...

// Validation personnalisée via callback
if ($validator = $config->getOption('custom_validator')) {
$customErrors = call_user_func($validator, $value, $config);
$errors = array_merge($errors, $customErrors);
}

return $errors;
}

2. Normalisation des données

Toujours normaliser les données avant la sauvegarde :

public function normalizeValue(mixed $value, EditableFieldConfiguration $config): mixed
{
// Nettoyer les espaces
$value = trim($value);

// Conversion de type appropriée
return $this->convertToExpectedType($value, $config);
}

3. Accessibilité

Assurez-vous que vos renderers sont accessibles :

protected function doRender(...): string
{
return sprintf(
'<input type="text"
aria-label="%s"
aria-required="%s"
aria-invalid="%s"
role="textbox"
%s>',
$config->getLabel(),
$config->isRequired() ? 'true' : 'false',
$hasErrors ? 'true' : 'false',
$attributes
);
}

4. Performance

Pour les renderers complexes, utilisez la mise en cache :

private array $cache = [];

protected function doRender(...): string
{
$cacheKey = sprintf('%s-%s-%s', get_class($entity), $entity->getId(), $fieldName);

if (!isset($this->cache[$cacheKey])) {
$this->cache[$cacheKey] = $this->generateComplexHtml($config, $value, $entity, $fieldName);
}

return $this->cache[$cacheKey];
}

Exemples Avancés

Renderer avec Auto-complétion

class AutocompleteFieldRenderer extends AbstractFieldRenderer
{
public function supports(EditableFieldConfiguration $config): bool
{
return $config->getFieldType() === 'autocomplete';
}

protected function doRender(...): string
{
$sourceUrl = $config->getOption('source_url');
$minLength = $config->getOption('min_length', 2);

return sprintf(
'<input type="text"
class="form-control autocomplete-field"
data-entity-id="%s"
data-field-name="%s"
data-source-url="%s"
data-min-length="%d"
value="%s"
autocomplete="off">',
$entity->getId(),
$fieldName,
$sourceUrl,
$minLength,
htmlspecialchars($value)
);
}
}

Renderer avec Validation Asynchrone

class AsyncValidatedFieldRenderer extends AbstractFieldRenderer
{
protected function doRender(...): string
{
$validationUrl = $config->getOption('validation_url');

return sprintf(
'<input type="text"
class="form-control async-validated"
data-entity-id="%s"
data-field-name="%s"
data-validation-url="%s"
data-validation-delay="500"
value="%s">
<div class="validation-feedback"></div>',
$entity->getId(),
$fieldName,
$validationUrl,
htmlspecialchars($value)
);
}
}

Ressources