Aller au contenu principal

Architecture du Bundle

Guide détaillé de l'architecture interne du SigmasoftDataTableBundle. 🏗️

Vue d'ensemble​

Le SigmasoftDataTableBundle utilise une architecture modulaire basée sur les principes SOLID et les design patterns modernes.

Patterns Architecturaux​

1. Builder Pattern​

Le DataTableBuilder utilise le pattern Builder pour une configuration fluide :

namespace Sigmasoft\DataTableBundle\Builder;

final class DataTableBuilder
{
private DataTableConfiguration $configuration;

public function createDataTable(string $entityClass): self
{
$this->configuration = new DataTableConfiguration($entityClass);
return $this;
}

public function addColumn(ColumnInterface $column): self
{
$this->configuration->addColumn($column);
return $this;
}

// Chaînage de méthodes pour configuration fluide
public function setLabel(string $label): self
{
$this->configuration->setLabel($label);
return $this;
}
}

2. Factory Pattern​

Les factories créent des objets complexes de manière cohérente :

namespace Sigmasoft\DataTableBundle\Factory;

class EditableColumnFactory
{
public function text(string $name, string $property, string $label): EditableColumnV2
{
return new EditableColumnV2(
name: $name,
property: $property,
label: $label,
type: 'text',
renderer: $this->getRenderer('text')
);
}

public function select(string $name, string $property, string $label, array $choices): EditableColumnV2
{
$column = new EditableColumnV2(
name: $name,
property: $property,
label: $label,
type: 'select',
renderer: $this->getRenderer('select')
);

$column->setChoices($choices);
return $column;
}
}

3. Strategy Pattern​

Les renderers utilisent le pattern Strategy pour un rendu flexible :

namespace Sigmasoft\DataTableBundle\InlineEdit\Renderer;

interface FieldRendererInterface
{
public function supports(EditableFieldConfiguration $config): bool;
public function render(EditableFieldConfiguration $config, mixed $value, object $entity): string;
}

abstract class AbstractFieldRenderer implements FieldRendererInterface
{
abstract public function supports(EditableFieldConfiguration $config): bool;

protected function escapeHtml(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

protected function generateFieldId(object $entity, string $field): string
{
return sprintf('inline-edit-%s-%s', $entity->getId(), $field);
}
}

4. Registry Pattern​

Le FieldRendererRegistry centralise la gestion des renderers :

namespace Sigmasoft\DataTableBundle\InlineEdit\Renderer;

class FieldRendererRegistry
{
/** @var array<string, FieldRendererInterface> */
private array $renderers = [];

public function addRenderer(FieldRendererInterface $renderer): void
{
$this->renderers[] = $renderer;
}

public function getRenderer(EditableFieldConfiguration $config): FieldRendererInterface
{
foreach ($this->renderers as $renderer) {
if ($renderer->supports($config)) {
return $renderer;
}
}

throw new \RuntimeException(sprintf(
'No renderer found for field type "%s"',
$config->getFieldType()
));
}
}

Architecture en Couches​

1. Couche Présentation​

Live Components et Templates Twig

namespace Sigmasoft\DataTableBundle\Component;

#[AsLiveComponent('SigmasoftDataTable')]
class DataTableComponent
{
use DefaultActionTrait;

#[ExposeInTemplate]
public string $entityClass;

#[ExposeInTemplate]
public array $configuration = [];

#[LiveProp(writable: true)]
public int $page = 1;

#[LiveProp(writable: true)]
public string $search = '';

public function __invoke(): Response
{
return $this->render('@SigmasoftDataTable/components/DataTable.html.twig', [
'data' => $this->loadData(),
'config' => $this->configuration
]);
}
}

2. Couche Service​

Services principaux et logique métier

namespace Sigmasoft\DataTableBundle\Service;

class InlineEditServiceV2
{
public function __construct(
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator,
private FieldRendererRegistry $rendererRegistry,
private LoggerInterface $logger
) {}

public function updateField(
object $entity,
string $field,
mixed $value,
EditableFieldConfiguration $config
): InlineEditResult {
// Début de transaction
$this->entityManager->beginTransaction();

try {
// Validation
$violations = $this->validateField($entity, $field, $value, $config);
if (count($violations) > 0) {
throw new ValidationException($violations);
}

// Mise à jour
$this->propertyAccessor->setValue($entity, $field, $value);

// Persistance
$this->entityManager->persist($entity);
$this->entityManager->flush();
$this->entityManager->commit();

return InlineEditResult::success($entity, $field, $value);

} catch (\Exception $e) {
$this->entityManager->rollback();
$this->logger->error('Inline edit failed', [
'entity' => get_class($entity),
'field' => $field,
'error' => $e->getMessage()
]);

return InlineEditResult::error($e->getMessage());
}
}
}

3. Couche Données​

Data Providers et Repository Pattern

namespace Sigmasoft\DataTableBundle\DataProvider;

class DoctrineDataProvider implements DataProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private PaginatorInterface $paginator
) {}

public function getData(
string $entityClass,
int $page = 1,
int $limit = 25,
?string $search = null,
?string $sort = null,
?string $direction = 'asc'
): DataTableResultInterface {
$repository = $this->entityManager->getRepository($entityClass);
$queryBuilder = $repository->createQueryBuilder('e');

// Recherche
if ($search) {
$this->applySearch($queryBuilder, $search, $entityClass);
}

// Tri
if ($sort) {
$queryBuilder->orderBy('e.' . $sort, $direction);
}

// Pagination
$pagination = $this->paginator->paginate(
$queryBuilder,
$page,
$limit
);

return new DataTableResult(
items: $pagination->getItems(),
totalItems: $pagination->getTotalItemCount(),
itemsPerPage: $limit,
currentPage: $page
);
}
}

Configuration et Injection de Dépendances​

Configuration du Bundle​

namespace Sigmasoft\DataTableBundle\DependencyInjection;

class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('sigmasoft_data_table');
$rootNode = $treeBuilder->getRootNode();

$rootNode
->children()
->arrayNode('defaults')
->children()
->integerNode('items_per_page')->defaultValue(25)->end()
->booleanNode('enable_search')->defaultTrue()->end()
->booleanNode('enable_sort')->defaultTrue()->end()
->booleanNode('enable_pagination')->defaultTrue()->end()
->scalarNode('table_class')->defaultValue('table table-striped')->end()
->scalarNode('date_format')->defaultValue('d/m/Y H:i')->end()
->end()
->end()
->arrayNode('entities')
->useAttributeAsKey('class')
->arrayPrototype()
->children()
->scalarNode('label')->isRequired()->end()
->arrayNode('fields')
->useAttributeAsKey('name')
->arrayPrototype()
->children()
->scalarNode('type')->isRequired()->end()
->scalarNode('label')->isRequired()->end()
->booleanNode('sortable')->defaultTrue()->end()
->booleanNode('searchable')->defaultTrue()->end()
->end()
->end()
->end()
->end()
->end()
->end()
->end();

return $treeBuilder;
}
}

Compiler Pass​

namespace Sigmasoft\DataTableBundle\DependencyInjection\Compiler;

class FieldRendererPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->has(FieldRendererRegistry::class)) {
return;
}

$registry = $container->findDefinition(FieldRendererRegistry::class);
$taggedServices = $container->findTaggedServiceIds('sigmasoft.field_renderer');

foreach ($taggedServices as $id => $tags) {
$registry->addMethodCall('addRenderer', [new Reference($id)]);
}
}
}

Sécurité et Validation​

Architecture de Sécurité​

namespace Sigmasoft\DataTableBundle\Security;

class DataTableVoter extends Voter
{
public const VIEW = 'DATATABLE_VIEW';
public const EDIT = 'DATATABLE_EDIT';
public const DELETE = 'DATATABLE_DELETE';

protected function supports(string $attribute, $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT, self::DELETE])
&& $subject instanceof DataTableConfiguration;
}

protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
{
$user = $token->getUser();

if (!$user instanceof UserInterface) {
return false;
}

return match($attribute) {
self::VIEW => $this->canView($subject, $user),
self::EDIT => $this->canEdit($subject, $user),
self::DELETE => $this->canDelete($subject, $user),
default => false
};
}
}

Validation en Profondeur​

namespace Sigmasoft\DataTableBundle\Validation;

class FieldValidator
{
public function __construct(
private ValidatorInterface $validator,
private PropertyAccessorInterface $propertyAccessor
) {}

public function validateField(
object $entity,
string $field,
mixed $value,
array $constraints = []
): ConstraintViolationListInterface {
// Validation des contraintes personnalisées
if (!empty($constraints)) {
return $this->validator->validate($value, $constraints);
}

// Validation via annotations/attributs de l'entité
$metadata = $this->validator->getMetadataFor($entity);

if ($metadata->hasPropertyMetadata($field)) {
$propertyMetadata = $metadata->getPropertyMetadata($field);
$constraints = [];

foreach ($propertyMetadata as $member) {
$constraints = array_merge($constraints, $member->getConstraints());
}

return $this->validator->validate($value, $constraints);
}

return new ConstraintViolationList();
}
}

Performance et Optimisation​

Stratégies de Cache​

namespace Sigmasoft\DataTableBundle\Cache;

class DataTableCache
{
private CacheInterface $cache;

public function getConfiguration(string $entityClass): ?DataTableConfiguration
{
$cacheKey = 'datatable_config_' . md5($entityClass);

return $this->cache->get($cacheKey, function (ItemInterface $item) use ($entityClass) {
$item->expiresAfter(3600); // 1 heure

// Génération de la configuration
return $this->configResolver->resolve($entityClass);
});
}

public function invalidate(string $entityClass): void
{
$cacheKey = 'datatable_config_' . md5($entityClass);
$this->cache->delete($cacheKey);
}
}

Optimisation des Requêtes​

namespace Sigmasoft\DataTableBundle\Query;

class QueryOptimizer
{
public function optimizeQuery(QueryBuilder $queryBuilder, array $columns): void
{
// Sélection partielle pour performance
$select = ['e.id'];
foreach ($columns as $column) {
if ($column->isVisible()) {
$select[] = 'e.' . $column->getProperty();
}
}
$queryBuilder->select($select);

// Eager loading des relations
$this->addEagerLoading($queryBuilder, $columns);

// Index hints si supporté
if ($this->supportsIndexHints()) {
$this->addIndexHints($queryBuilder);
}
}

private function addEagerLoading(QueryBuilder $queryBuilder, array $columns): void
{
foreach ($columns as $column) {
if ($column instanceof RelationColumn) {
$queryBuilder->leftJoin('e.' . $column->getRelation(), $column->getRelation())
->addSelect($column->getRelation());
}
}
}
}

Tests et Qualité​

Structure des Tests​

tests/
├── Unit/
│ ├── Builder/
│ │ └── DataTableBuilderTest.php
│ ├── Column/
│ │ └── EditableColumnTest.php
│ └── Service/
│ └── InlineEditServiceV2Test.php
├── Integration/
│ ├── Component/
│ │ └── DataTableComponentTest.php
│ └── DataProvider/
│ └── DoctrineDataProviderTest.php
└── Functional/
├── Controller/
│ └── DataTableControllerTest.php
└── Maker/
└── MakeDataTableTest.php

Tests Unitaires​

namespace Sigmasoft\DataTableBundle\Tests\Unit\Service;

class InlineEditServiceV2Test extends TestCase
{
private InlineEditServiceV2 $service;
private EntityManagerInterface $entityManager;
private ValidatorInterface $validator;

protected function setUp(): void
{
$this->entityManager = $this->createMock(EntityManagerInterface::class);
$this->validator = $this->createMock(ValidatorInterface::class);

$this->service = new InlineEditServiceV2(
$this->entityManager,
$this->validator,
new FieldRendererRegistry(),
new NullLogger()
);
}

public function testUpdateFieldSuccess(): void
{
$entity = new User();
$entity->setName('Old Name');

$this->validator->expects($this->once())
->method('validate')
->willReturn(new ConstraintViolationList());

$this->entityManager->expects($this->once())
->method('beginTransaction');

$this->entityManager->expects($this->once())
->method('persist')
->with($entity);

$this->entityManager->expects($this->once())
->method('flush');

$this->entityManager->expects($this->once())
->method('commit');

$config = new EditableFieldConfiguration('name', 'text');
$result = $this->service->updateField($entity, 'name', 'New Name', $config);

$this->assertTrue($result->isSuccess());
$this->assertEquals('New Name', $entity->getName());
}
}

Extension et Personnalisation​

Créer un Renderer Personnalisé​

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 renderView(
EditableFieldConfiguration $config,
mixed $value,
object $entity,
array $options = []
): string {
$rating = (int) $value;
$maxRating = $options['max_rating'] ?? 5;

$stars = str_repeat('★', $rating) . str_repeat('☆', $maxRating - $rating);

return sprintf(
'<span class="rating-field" data-rating="%d">%s</span>',
$rating,
$stars
);
}

protected function renderEditForm(
EditableFieldConfiguration $config,
mixed $value,
object $entity,
array $options = []
): string {
$maxRating = $options['max_rating'] ?? 5;
$html = '<div class="rating-edit">';

for ($i = 1; $i <= $maxRating; $i++) {
$checked = $i <= $value ? 'checked' : '';
$html .= sprintf(
'<input type="radio" name="rating_%s" value="%d" %s>',
$config->getFieldName(),
$i,
$checked
);
}

$html .= '</div>';
return $html;
}
}

Enregistrement du Renderer​

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

JavaScript et Frontend​

Architecture Stimulus​

// assets/controllers/datatable_controller.js
import { Controller } from '@hotwired/stimulus'
import { InlineEditManager } from '../services/InlineEditManager'

export default class extends Controller {
static targets = ['table', 'search', 'pagination']
static values = {
url: String,
entityClass: String
}

connect() {
this.inlineEditManager = new InlineEditManager(this.element)
this.initializeEventListeners()
}

initializeEventListeners() {
// Recherche en temps réel
this.searchTarget.addEventListener('input',
this.debounce(this.search.bind(this), 500)
)

// Édition inline
this.element.addEventListener('inline-edit:start', this.onEditStart.bind(this))
this.element.addEventListener('inline-edit:save', this.onEditSave.bind(this))
this.element.addEventListener('inline-edit:cancel', this.onEditCancel.bind(this))
}

async search(event) {
const query = event.target.value

const response = await fetch(this.urlValue, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
search: query,
entity: this.entityClassValue
})
})

if (response.ok) {
const html = await response.text()
this.updateTable(html)
}
}

debounce(func, wait) {
let timeout
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout)
func(...args)
}
clearTimeout(timeout)
timeout = setTimeout(later, wait)
}
}
}

Service d'Édition Inline​

// assets/services/InlineEditManager.js
export class InlineEditManager {
constructor(element) {
this.element = element
this.editingFields = new Map()
this.init()
}

init() {
this.element.addEventListener('click', (e) => {
if (e.target.closest('.inline-editable')) {
this.startEdit(e.target.closest('.inline-editable'))
}
})

// Raccourcis clavier
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.cancelAllEdits()
}
if (e.key === 'Enter' && e.ctrlKey) {
this.saveAllEdits()
}
})
}

startEdit(field) {
const fieldId = field.dataset.fieldId
const fieldType = field.dataset.fieldType

if (this.editingFields.has(fieldId)) {
return
}

const editor = this.createEditor(fieldType, field)
this.editingFields.set(fieldId, {
field,
editor,
originalValue: field.textContent
})

field.replaceWith(editor)
editor.focus()
}

createEditor(type, field) {
switch (type) {
case 'text':
return this.createTextEditor(field)
case 'select':
return this.createSelectEditor(field)
case 'textarea':
return this.createTextareaEditor(field)
default:
return this.createTextEditor(field)
}
}

async save(fieldId) {
const editData = this.editingFields.get(fieldId)
if (!editData) return

const { field, editor } = editData
const newValue = editor.value

try {
const response = await fetch('/datatable/inline-edit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCsrfToken()
},
body: JSON.stringify({
entity: field.dataset.entity,
id: field.dataset.entityId,
field: field.dataset.field,
value: newValue
})
})

if (response.ok) {
field.textContent = newValue
editor.replaceWith(field)
this.editingFields.delete(fieldId)
this.showSuccess('Sauvegardé avec succès')
} else {
const error = await response.json()
this.showError(error.message)
}
} catch (error) {
this.showError('Erreur de connexion')
}
}

getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || ''
}
}

Support et Ressources​

Documentation Complète​

Communauté et Support​


Documentation technique rédigée par Gédéon MAKELA - Sigmasoft Solutions