Narrow edit columns for wider amounts, modal-based signalement, fix scroll storm
Three related /lignes fixes: 1. Column widths: shrink Date/Facture/Libellé/Signalement (the four editable text columns) to free up room for the amount columns (Montant HT/TTC, the 8 compte columns, Écart), which were cramped. Date/Facture stay nowrap (already short: jj/mm/aa, Fxxxxxxx); Libellé/ Signalement keep wrapping. 2. Signalement editing: replaced the comma-separated inline text input with a small modal -- one tag per line, each with its own remove button, plus an add field at the bottom. Clearer than parsing/ retyping a whole comma list to drop one tag. Backend endpoint is unchanged (still takes a comma-joined value); only the front-end interaction model changed. 3. Scroll storm: a continuous scroll gesture fires many native 'scroll' events, and checkEdges() ran on every one of them -- each qualifying event queued its own loadOlder()/loadNewer() call (queuing, not dropping, was the previous session's fix for a *different* bug), and every queued call did a real fetch + scroll compensation regardless of whether an earlier one already moved the window away from the edge. That pileup is what looked like the same request firing over and over and dragged the scroll position around unpredictably. Fixed by guarding checkEdges() with the existing loadingOlder/ loadingNewer flags so it stops queuing once one's already in flight. (A first attempt at this suppressed the compensation write's own resulting scroll event via a flag cleared on requestAnimationFrame -- reproduced, live, the exact "stuck forever" failure already fixed once this session for the old rAF-based scroll throttle, because rAF doesn't reliably fire in this environment. Removed: turns out no suppression is needed at all, since that event finds loadingOlder already true and the checkEdges() guard blocks it on its own.) Also added a re-entrancy guard to pollForChanges(), which had no protection against a slow response overlapping with the next setInterval tick. Verified with scripted scroll stress tests (dense bursts of 40-100 events, and repeated attempts to scroll back from the very top): exactly one fetch per genuine edge crossing, no duplicate requests, scroll position stays correctly anchored, never snaps back down. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -228,13 +228,13 @@ html.gin--dark-mode #figli-home-app {
|
||||
|
||||
#figli-home-app th:nth-child(6),
|
||||
#figli-home-app td.figli-libelle {
|
||||
width: 15%;
|
||||
width: 10%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
#figli-home-app th:nth-child(7),
|
||||
#figli-home-app td.figli-flag-cell {
|
||||
width: 8%;
|
||||
width: 6%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -424,7 +424,7 @@ html.gin--dark-mode #figli-home-app {
|
||||
scaled down to fit. */
|
||||
#figli-home-app th:nth-child(2),
|
||||
#figli-home-app td:nth-child(2) {
|
||||
width: 6%;
|
||||
width: 5%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#figli-home-app th:nth-child(3),
|
||||
@@ -437,15 +437,15 @@ html.gin--dark-mode #figli-home-app {
|
||||
}
|
||||
#figli-home-app th:nth-child(5),
|
||||
#figli-home-app td:nth-child(5) {
|
||||
width: 5.5%;
|
||||
width: 5%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#figli-home-app .amount:not(.compte-col) {
|
||||
width: 5.5%;
|
||||
width: 6.2%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#figli-home-app .amount.compte-col {
|
||||
width: 3.5%;
|
||||
width: 4.3%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -556,6 +556,54 @@ html.gin--dark-mode #figli-home-app {
|
||||
border-color: var(--figli-border);
|
||||
color: var(--figli-text);
|
||||
}
|
||||
/* Signalement modal -- much smaller than the entrées/sorties drill-down
|
||||
above (a short list of tags, not a table), so it gets its own narrow
|
||||
width instead of the 96vw default. */
|
||||
#figli-home-app .figli-flag-modal {
|
||||
width: 24rem;
|
||||
max-width: 90vw;
|
||||
}
|
||||
#figli-home-app .figli-flag-modal-body {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
}
|
||||
#figli-home-app .figli-flag-modal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0;
|
||||
border-bottom: 1px solid var(--figli-border);
|
||||
}
|
||||
#figli-home-app .figli-flag-remove {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--figli-text-light);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
flex: none;
|
||||
}
|
||||
#figli-home-app .figli-flag-remove:hover {
|
||||
background: var(--figli-bg-alt);
|
||||
border-color: var(--figli-border);
|
||||
color: var(--figli-error);
|
||||
}
|
||||
#figli-home-app .figli-flag-modal-add {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
#figli-home-app .figli-flag-modal-add input {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--figli-border);
|
||||
border-radius: 4px;
|
||||
background: var(--figli-bg);
|
||||
color: var(--figli-text);
|
||||
}
|
||||
|
||||
#figli-home-app .figli-modal-body {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
@@ -433,6 +433,12 @@
|
||||
// its inline <input> instead of the plain text -- only one at a
|
||||
// time, mirroring editingTypeId above. { id, field } or null.
|
||||
editingCell: null,
|
||||
// Row currently open in the signalement modal (see
|
||||
// openFlagModal()) -- a small standalone object, not the row
|
||||
// itself, and the text of a not-yet-added tag typed into that
|
||||
// modal's own input.
|
||||
flagModalItem: null,
|
||||
flagModalNewTag: '',
|
||||
// Fenêtre glissante.
|
||||
windowStart: null,
|
||||
windowEnd: null,
|
||||
@@ -895,6 +901,13 @@
|
||||
setInterval(() => this.pollForChanges(), POLL_INTERVAL_MS);
|
||||
},
|
||||
async pollForChanges() {
|
||||
// setInterval doesn't wait for a previous callback's promise to
|
||||
// settle before scheduling the next tick -- without this guard,
|
||||
// a poll that takes longer than POLL_INTERVAL_MS to respond
|
||||
// (slow network, tab was backgrounded and just resumed) would
|
||||
// overlap with the next one, both merging into `rows` at once.
|
||||
if (this._pollInFlight) return;
|
||||
this._pollInFlight = true;
|
||||
try {
|
||||
const { data, includedMap } = await fetchChangedSince(this.lastPollTs);
|
||||
if (data.length) {
|
||||
@@ -903,6 +916,8 @@
|
||||
this.lastPollTs = Math.floor(Date.now() / 1000);
|
||||
} catch (err) {
|
||||
// silent -- see comment above.
|
||||
} finally {
|
||||
this._pollInFlight = false;
|
||||
}
|
||||
},
|
||||
// Patches already-loaded rows in place (mutates each row's own
|
||||
@@ -975,46 +990,84 @@
|
||||
return !!this.editingCell && this.editingCell.id === item.id && this.editingCell.field === field;
|
||||
},
|
||||
// Same optimistic-patch-then-close pattern as saveType() above --
|
||||
// client/facture/libellé/flag don't affect linkability or
|
||||
// client/facture/libellé don't affect linkability or
|
||||
// field_entree_liee, so there's nothing else to reconcile via
|
||||
// reloadWindow() here.
|
||||
// reloadWindow() here. Signalement (field_flag) isn't handled here
|
||||
// -- it's multi-value and edited through its own modal, see
|
||||
// openFlagModal()/saveFlagList() below.
|
||||
async saveCell(item, field, event) {
|
||||
const newValue = event.target.value.trim();
|
||||
this.editingCell = null;
|
||||
// field_flag is multi-value -- the row carries it as `flags`
|
||||
// (array), not `flag`, and the input works with the comma-joined
|
||||
// text form of it (see the 'flag' case in the template).
|
||||
const previousValue = field === 'flag' ? item.flags.join(', ') : (item[field] || '');
|
||||
if (newValue === previousValue) return;
|
||||
if (newValue === (item[field] || '')) return;
|
||||
try {
|
||||
const result = await updateLigneField(item.nid, field, newValue, item.changed);
|
||||
const row = this.rows.find((r) => r.id === item.id);
|
||||
if (row) {
|
||||
if (field === 'flag') {
|
||||
row.flags = result.value;
|
||||
row.hasFlag = result.value.length > 0;
|
||||
} else {
|
||||
row[field] = result.value;
|
||||
}
|
||||
row[field] = result.value;
|
||||
row.changed = result.changed;
|
||||
}
|
||||
// A client/tag name with no existing match gets created on the
|
||||
// fly (server-side) rather than rejected -- reflect it in the
|
||||
// A client name with no existing match gets created on the fly
|
||||
// (server-side) rather than rejected -- reflect it in the
|
||||
// filter dropdown/datalist immediately instead of only after a
|
||||
// reload picks up the new taxonomy term via fetchClientNames()/
|
||||
// fetchFlagNames().
|
||||
// reload picks up the new taxonomy term via fetchClientNames().
|
||||
if (field === 'client' && result.value && !this.allClientsList.includes(result.value)) {
|
||||
this.allClientsList = [...this.allClientsList, result.value].sort();
|
||||
}
|
||||
if (field === 'flag') {
|
||||
const newNames = result.value.filter((name) => !this.allFlagsList.includes(name));
|
||||
if (newNames.length) this.allFlagsList = [...this.allFlagsList, ...newNames].sort();
|
||||
}
|
||||
} catch (err) {
|
||||
this.typeUpdateError = err.message;
|
||||
if (err.status === 409) this.refreshSingleRow(item.id, item.nid);
|
||||
}
|
||||
},
|
||||
// Signalement modal: one tag per line (add/remove), not a single
|
||||
// comma-separated text field -- easier to see what's already there
|
||||
// and remove just one without retyping the rest. flagModalItem is
|
||||
// its own small reactive object (not aliased to the row in `rows`)
|
||||
// so the modal keeps working even if the underlying row gets
|
||||
// trimmed out of the sliding window while it's open; saveFlagList()
|
||||
// updates both explicitly.
|
||||
openFlagModal(item) {
|
||||
this.typeUpdateError = null;
|
||||
this.flagModalItem = { id: item.id, nid: item.nid, date: item.date, client: item.client, changed: item.changed, flags: item.flags.slice() };
|
||||
this.flagModalNewTag = '';
|
||||
},
|
||||
closeFlagModal() {
|
||||
this.flagModalItem = null;
|
||||
this.flagModalNewTag = '';
|
||||
},
|
||||
async addFlagTag() {
|
||||
const name = this.flagModalNewTag.trim();
|
||||
if (!name || this.flagModalItem.flags.includes(name)) {
|
||||
this.flagModalNewTag = '';
|
||||
return;
|
||||
}
|
||||
this.flagModalNewTag = '';
|
||||
await this.saveFlagList([...this.flagModalItem.flags, name]);
|
||||
},
|
||||
async removeFlagTag(name) {
|
||||
await this.saveFlagList(this.flagModalItem.flags.filter((f) => f !== name));
|
||||
},
|
||||
async saveFlagList(newFlags) {
|
||||
const item = this.flagModalItem;
|
||||
try {
|
||||
const result = await updateLigneField(item.nid, 'flag', newFlags.join(', '), item.changed);
|
||||
item.flags = result.value;
|
||||
item.changed = result.changed;
|
||||
const row = this.rows.find((r) => r.id === item.id) || this.groupExtraRows.find((r) => r.id === item.id);
|
||||
if (row) {
|
||||
row.flags = result.value;
|
||||
row.hasFlag = result.value.length > 0;
|
||||
row.changed = result.changed;
|
||||
}
|
||||
const newNames = result.value.filter((name) => !this.allFlagsList.includes(name));
|
||||
if (newNames.length) this.allFlagsList = [...this.allFlagsList, ...newNames].sort();
|
||||
} catch (err) {
|
||||
this.typeUpdateError = err.message;
|
||||
if (err.status === 409) {
|
||||
this.refreshSingleRow(item.id, item.nid);
|
||||
this.closeFlagModal();
|
||||
}
|
||||
}
|
||||
},
|
||||
// 409 conflict from saveType()/saveCell() above -- someone else
|
||||
// saved this exact line in between. Re-fetch just this one node
|
||||
// and patch it in place, so the row reflects their change right
|
||||
@@ -1175,7 +1228,12 @@
|
||||
this.rows = [...newRows, ...this.rows];
|
||||
await this.$nextTick();
|
||||
// Prepending pushes existing content down -- keep whatever
|
||||
// the user was looking at in the same visual spot.
|
||||
// the user was looking at in the same visual spot. This
|
||||
// write fires its own native 'scroll' event, re-entering
|
||||
// checkEdges() -- harmless: loadingOlder is still true at
|
||||
// this point (reset only in the `finally` below), so that
|
||||
// event finds the guard there already blocking a redundant
|
||||
// call, no separate suppression needed.
|
||||
if (wrap) wrap.scrollTop += wrap.scrollHeight - prevScrollHeight;
|
||||
}
|
||||
|
||||
@@ -1246,8 +1304,21 @@
|
||||
// waiting for a scroll that can never happen with nothing to
|
||||
// scroll.
|
||||
const notScrollable = wrap.scrollHeight <= wrap.clientHeight;
|
||||
if (notScrollable || wrap.scrollTop < threshold) this.loadOlder();
|
||||
if (notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) this.loadNewer();
|
||||
// Guarded by loadingOlder/loadingNewer, not just called
|
||||
// unconditionally: loadOlder()/loadNewer() queue instead of
|
||||
// dropping a call that arrives while one's already running (see
|
||||
// _queueWindowOp) -- correct for the *distinct* triggers that fix
|
||||
// was about (several filters changing in the same tick), but a
|
||||
// single continuous scroll gesture fires many native 'scroll'
|
||||
// events, and checkEdges() runs on every one of them. Without
|
||||
// this guard, every one of those events near an edge queued its
|
||||
// own loadOlder()/loadNewer() call, and every queued call did a
|
||||
// real fetch + scroll compensation regardless of whether the
|
||||
// very first one already moved the window away from the edge --
|
||||
// that pileup is what looked like the same request firing over
|
||||
// and over, dragging the scroll position around unpredictably.
|
||||
if ((notScrollable || wrap.scrollTop < threshold) && !this.loadingOlder) this.loadOlder();
|
||||
if ((notScrollable || wrap.scrollTop + wrap.clientHeight > wrap.scrollHeight - threshold) && !this.loadingNewer) this.loadNewer();
|
||||
},
|
||||
// Whether compte/client/type/écarts thin filteredRows down from
|
||||
// whatever's actually loaded -- loadOlder()/loadNewer() use this to
|
||||
|
||||
@@ -219,18 +219,7 @@
|
||||
</template>
|
||||
</td>
|
||||
<td class="figli-flag-cell">
|
||||
<input
|
||||
v-if="isEditingCell(item, 'flag')"
|
||||
v-focus
|
||||
type="text"
|
||||
class="figli-inline-input"
|
||||
list="figli-flag-datalist"
|
||||
:value="item.flags.join(', ')"
|
||||
@change="saveCell(item, 'flag', $event)"
|
||||
@blur="editingCell = null"
|
||||
@keyup.enter="$event.target.blur()"
|
||||
/>
|
||||
<span v-else class="figli-editable-cell" title="Cliquer pour signaler un problème" @click="startEditCell(item, 'flag')">
|
||||
<span class="figli-editable-cell" title="Cliquer pour gérer le signalement" @click="openFlagModal(item)">
|
||||
<template v-if="item.flags.length"><span v-for="f in item.flags" :key="f" class="figli-flag-badge">{{ f }}</span></template>
|
||||
<template v-else>—</template>
|
||||
</span>
|
||||
@@ -331,5 +320,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="flagModalItem" class="figli-modal-backdrop" @click.self="closeFlagModal">
|
||||
<div class="figli-modal figli-flag-modal">
|
||||
<div class="figli-modal-header">
|
||||
<h3>Signalement -- {{ formatDate(flagModalItem.date) }}{{ flagModalItem.client ? ' · ' + flagModalItem.client : '' }}</h3>
|
||||
<button type="button" class="figli-modal-close" @click="closeFlagModal">✕ Fermer</button>
|
||||
</div>
|
||||
<div class="figli-flag-modal-body">
|
||||
<p v-if="!flagModalItem.flags.length" class="figli-note">Aucun signalement pour cette ligne.</p>
|
||||
<div v-for="f in flagModalItem.flags" :key="f" class="figli-flag-modal-row">
|
||||
<span class="figli-flag-badge">{{ f }}</span>
|
||||
<button type="button" class="figli-flag-remove" title="Retirer ce signalement" @click="removeFlagTag(f)">✕</button>
|
||||
</div>
|
||||
<div class="figli-flag-modal-add">
|
||||
<input
|
||||
v-focus
|
||||
type="text"
|
||||
list="figli-flag-datalist"
|
||||
placeholder="Ajouter un signalement…"
|
||||
v-model="flagModalNewTag"
|
||||
@keyup.enter="addFlagTag"
|
||||
/>
|
||||
<button type="button" class="button" @click="addFlagTag">Ajouter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endverbatim %}
|
||||
|
||||
Reference in New Issue
Block a user