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
@@ -217,6 +217,24 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app .type-ouverture { background: #7c3aed1a; color: #9061f0; }
#figli-home-app .type-autre { background: #6b72801a; color: var(--figli-text-light); }
/* Click-to-edit type badge -- swaps for a native <select> in place
(figli-type-select below), no modal needed for this one field. */
#figli-home-app .figli-badge-editable {
cursor: pointer;
border: 1px solid transparent;
}
#figli-home-app .figli-badge-editable:hover {
border-color: currentColor;
}
#figli-home-app .figli-type-select {
font-size: 0.72rem;
padding: 0.05rem 0.2rem;
background: var(--figli-bg);
color: var(--figli-text);
border: 1px solid var(--figli-border);
border-radius: 4px;
}
#figli-home-app .figli-note {
color: var(--figli-text-light);
font-weight: 400;
@@ -315,3 +333,14 @@ html.gin--dark-mode #figli-home-app {
padding: 0.75rem 1rem;
border-radius: 6px;
}
/* Dismissible, doesn't replace the table like the top-level fetch error
does -- a failed inline type change is a small hiccup, not a reason to
hide everything that's already loaded. */
#figli-home-app .figli-inline-error {
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
@@ -54,3 +54,15 @@ figli_compta_ledger.api_reconciliation_ouverture:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerStatsController::reconciliationOuverture'
requirements:
_permission: 'access content'
figli_compta_ledger.update_type:
path: '/lignes/{node}/type'
defaults:
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateType'
methods: [POST]
requirements:
_entity_access: 'node.update'
options:
parameters:
node:
type: entity:node
@@ -129,6 +129,23 @@
return res.json();
}
// POST /lignes/{nid}/type -- change field_type_ligne without opening the
// full edit form. Fetches a fresh CSRF token each time (core's
// /session/token) rather than caching one -- this is an occasional
// action, not worth the complexity of handling a stale cached token.
async function updateLigneType(nid, type) {
const tokenRes = await fetch('/session/token');
const token = await tokenRes.text();
const res = await fetch('/lignes/' + nid + '/type', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
body: JSON.stringify({ type }),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/type a répondu ' + res.status));
return json;
}
async function fetchYearTotals(annee) {
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
@@ -202,6 +219,10 @@
onlyErrors: false,
hoverCol: null,
filterEntreeId: null,
// Which row's type badge is currently showing its inline <select>
// instead of the badge -- only one at a time.
editingTypeId: null,
typeUpdateError: null,
// Fenêtre glissante.
windowStart: null,
windowEnd: null,
@@ -396,6 +417,36 @@
toggleEntreeFilter(id) {
this.filterEntreeId = this.filterEntreeId === id ? null : id;
},
startEditType(item) {
this.typeUpdateError = null;
this.editingTypeId = item.id;
},
// Optimistically patches the row in `rows` (not the transient
// `item` from groupedRows -- that object is rebuilt from scratch on
// every computed re-evaluation, so mutating it wouldn't stick) for
// instant feedback, then reconciles with the server in the
// background via reloadWindow() (picks up anything else the save
// touched, e.g. a cleared field_entree_liee).
async saveType(item, event) {
const newType = event.target.value;
this.editingTypeId = null;
if (newType === item.type) return;
try {
const result = await updateLigneType(item.nid, newType);
const row = this.rows.find((r) => r.id === item.id);
if (row) {
row.type = newType;
row.linkable = LINKABLE_TYPES.includes(newType);
if (result.entree_liee_cleared) {
row.entreeLieeId = null;
row.entreeLieeLabel = null;
}
}
this.reloadWindow();
} catch (err) {
this.typeUpdateError = err.message;
}
},
onCellHover(evt) {
const cell = evt.target.closest('td, th');
if (!cell) return;
@@ -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),
]);
}
}
@@ -67,6 +67,8 @@
<span class="figli-count" v-if="!loading">{{ filteredRows.length }} / {{ rows.length }} lignes chargées{{ errorCount ? ' — ' + errorCount + ' avec écart' : '' }}</span>
</div>
<p v-if="typeUpdateError" class="figli-error figli-inline-error">Erreur : {{ typeUpdateError }} <button type="button" class="figli-clear-drilldown" @click="typeUpdateError = null">✕</button></p>
<p v-if="loading">Chargement des données…</p>
<p v-else-if="error" class="figli-error">Erreur de chargement : {{ error }}</p>
<div v-else ref="tableWrap" class="figli-table-wrap">
@@ -120,7 +122,24 @@
</td>
<td>{{ formatDate(item.date) }}</td>
<td>{{ item.client || '—' }}</td>
<td><span class="figli-badge" :class="'type-' + item.type">{{ typeLabel(item.type) }}</span></td>
<td>
<select
v-if="editingTypeId === item.id"
class="figli-type-select"
:value="item.type"
@change="saveType(item, $event)"
@blur="editingTypeId = null"
>
<option v-for="t in allTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
<span
v-else
class="figli-badge figli-badge-editable"
:class="'type-' + item.type"
title="Cliquer pour changer le type"
@click="startEditType(item)"
>{{ typeLabel(item.type) }}</span>
</td>
<td class="figli-libelle">
{{ item.libelle }}
<span