Add live polling and optimistic locking for concurrent inline edits
Two-part fix so multiple people can edit /lignes at once without clobbering each other, and see each other's changes without a manual reload -- no Socket.io/websocket infra, just what fits the existing fetch-based architecture: 1. Polling (POLL_INTERVAL_MS = 8s): pollForChanges() asks JSON:API for any ligne_comptable changed since the last check (filtering on the `changed` field -- confirmed live that JSON:API only accepts a raw Unix timestamp for this, not the ISO string it returns in responses, silently matching everything otherwise) and mergeChangedRows() patches matching rows in place via Object.assign (not a `rows` reassignment, so it doesn't re-trigger the reconciliation-resolution watcher for routine polls). A row currently being edited is skipped entirely rather than overwritten out from under an in-progress keystroke. 2. Optimistic locking: every row now carries its `changed` timestamp, sent back on every inline edit (updateType/updateField). A new checkConflict() compares it against the node's actual changed time before saving and rejects with 409 if they differ -- someone else saved this exact line in between. On a 409, refreshSingleRow() re-fetches just that node and patches it in place so the view self-corrects instead of staying stuck on the stale state that caused the rejection. Verified live end-to-end against an isolated temporary test node (not real data): an external edit correctly appeared in the browser within one poll cycle with no reload; a save using a stale `changed` value was rejected with the conflict error, confirmed via direct DB query that it left the node's data completely untouched, and the view auto-corrected to show the other edit. Test node and its paragraph fully cleaned up afterward.
This commit is contained in:
@@ -74,6 +74,10 @@ class LedgerActionsController extends ControllerBase {
|
||||
return new JsonResponse(['error' => 'Type de ligne invalide.'], 400);
|
||||
}
|
||||
|
||||
if ($conflict = $this->checkConflict($request, $node)) {
|
||||
return $conflict;
|
||||
}
|
||||
|
||||
$node->set('field_type_ligne', $type);
|
||||
|
||||
// A type that's no longer linkable shouldn't keep a stale
|
||||
@@ -110,6 +114,7 @@ class LedgerActionsController extends ControllerBase {
|
||||
'success' => TRUE,
|
||||
'type' => $type,
|
||||
'entree_liee_cleared' => !in_array($type, self::LINKABLE_TYPES, TRUE),
|
||||
'changed' => date(DATE_ATOM, $node->getChangedTime()),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -137,6 +142,10 @@ class LedgerActionsController extends ControllerBase {
|
||||
}
|
||||
$fieldName = self::INLINE_EDITABLE_FIELDS[$field];
|
||||
|
||||
if ($conflict = $this->checkConflict($request, $node)) {
|
||||
return $conflict;
|
||||
}
|
||||
|
||||
if ($field === 'client') {
|
||||
if ($value === '') {
|
||||
$node->set('field_client', NULL);
|
||||
@@ -186,7 +195,37 @@ class LedgerActionsController extends ControllerBase {
|
||||
'success' => TRUE,
|
||||
'field' => $field,
|
||||
'value' => $newValue,
|
||||
'changed' => date(DATE_ATOM, $node->getChangedTime()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic-locking guard shared by both endpoints above: the
|
||||
* frontend sends the `changed` timestamp of the row it last saw (see
|
||||
* buildRows() in home.js), captured at load/last-successful-save
|
||||
* time. If the node's *actual* changed time has since moved on --
|
||||
* someone else saved this same line in between -- the two won't
|
||||
* match, and we reject rather than silently overwrite whatever that
|
||||
* other save touched. Absent entirely (older cached frontend, or a
|
||||
* request that genuinely doesn't know it, e.g. a fresh row from
|
||||
* autocreate) skips the check rather than blocking on a false
|
||||
* mismatch -- the check is a safety net for the common case, not a
|
||||
* hard requirement of the API contract.
|
||||
*/
|
||||
private function checkConflict(Request $request, NodeInterface $node) {
|
||||
$data = json_decode($request->getContent(), TRUE);
|
||||
$clientChanged = is_array($data) ? ($data['changed'] ?? NULL) : NULL;
|
||||
if ($clientChanged === NULL) {
|
||||
return NULL;
|
||||
}
|
||||
$clientChangedTs = strtotime($clientChanged);
|
||||
if ($clientChangedTs !== FALSE && $clientChangedTs !== (int) $node->getChangedTime()) {
|
||||
return new JsonResponse([
|
||||
'error' => 'Cette ligne a été modifiée par quelqu\'un d\'autre entre-temps. Rechargez la page pour voir les dernières modifications.',
|
||||
'conflict' => TRUE,
|
||||
], 409);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user