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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user