Skip to main content

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​