Inline-edit the type badge directly in the table

Clicking a row's type badge swaps it for a native <select> in place;
picking a new value POSTs to a new endpoint
(LedgerActionsController::updateType) instead of opening the full
edit modal for this one field.

The endpoint goes through the normal node save() lifecycle, so
figli_compta_ledger_node_presave() still forces a proper revision and
still enforces the répartition invariant -- nothing here bypasses
that. It also clears a stale field_entree_liee when the new type is
no longer linkable (versement/achat/hébergement), mirroring the full
form's #states visibility rule. CSRF-protected via core's own
/session/token, scoped to CsrfRequestHeaderAccessCheck::TOKEN_KEY to
match what that endpoint actually generates. Verified end-to-end via
the real click flow: correct revision (user + timestamp), correct
optimistic UI update, correct field_entree_liee clearing, and 400/403
on invalid type / missing CSRF respectively.
This commit is contained in:
2026-09-04 21:57:33 +02:00
parent a45d55cb81
commit f260e8a605
5 changed files with 195 additions and 1 deletions
@@ -0,0 +1,83 @@
<?php
namespace Drupal\figli_compta_ledger\Controller;
use Drupal\Core\Access\CsrfRequestHeaderAccessCheck;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityStorageException;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
/**
* Small write endpoints backing inline (no-modal) edits from the /lignes
* table. Each goes through the normal node save() lifecycle -- same as the
* full edit form -- so figli_compta_ledger_node_presave() still forces a
* proper revision and still enforces the répartition invariant; nothing
* here bypasses that.
*/
class LedgerActionsController extends ControllerBase {
/**
* Every value field_type_ligne actually allows (see the field's
* allowed_values in config) -- validated against here rather than
* trusting the client.
*/
const ALLOWED_TYPES = ['entree', 'charge', 'versement', 'achat', 'hebergement', 'autre', 'ouverture'];
/**
* Types field_entree_liee is meaningful for -- mirrors the #states
* visibility rule in figli_compta_ledger_form_alter().
*/
const LINKABLE_TYPES = ['versement', 'achat', 'hebergement'];
/**
* POST /lignes/{node}/type -- change field_type_ligne without opening
* the full edit form, for clicking the type badge directly in the
* table. Body: {"type": "charge"}.
*/
public function updateType(Request $request, NodeInterface $node) {
if ($node->bundle() !== 'ligne_comptable') {
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
}
// Scoped to CsrfRequestHeaderAccessCheck::TOKEN_KEY -- the same value
// core's own /session/token controller generates against, which is
// what the frontend fetches this token from.
$csrfToken = $request->headers->get('X-CSRF-Token', '');
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
}
$data = json_decode($request->getContent(), TRUE);
$type = is_array($data) ? ($data['type'] ?? NULL) : NULL;
if (!in_array($type, self::ALLOWED_TYPES, TRUE)) {
return new JsonResponse(['error' => 'Type de ligne invalide.'], 400);
}
$node->set('field_type_ligne', $type);
// A type that's no longer linkable shouldn't keep a stale
// field_entree_liee reference around (mirrors the form's #states:
// charge/autre/ouverture/entree don't expose that field at all).
if (!in_array($type, self::LINKABLE_TYPES, TRUE)
&& $node->hasField('field_entree_liee')
&& !$node->get('field_entree_liee')->isEmpty()) {
$node->set('field_entree_liee', NULL);
}
try {
$node->save();
}
catch (EntityStorageException $e) {
return new JsonResponse(['error' => $e->getMessage()], 422);
}
return new JsonResponse([
'success' => TRUE,
'type' => $type,
'entree_liee_cleared' => !in_array($type, self::LINKABLE_TYPES, TRUE),
]);
}
}