Make filters and "Aller à" reloadable/shareable via the URL hash

#compte=Maud&client=EPAU&type=versement&annee=2023&ecarts=1&aller=2024
-- reload the page or send the link and the same filtered view (or
year jump) comes back.

readHashState()/buildHashString() handle the encoding; syncHash()
writes back via history.replaceState() (not pushState -- tweaking a
dropdown shouldn't spam the back button with history entries), called
from the existing filter watchers plus jumpToYear(). "aller" isn't an
ongoing filter (jumpYearValue always resets to '' right after firing)
so its target year is tracked separately (lastJumpYear) purely for the
hash, and omitted whenever "annee" is also present -- the two describe
overlapping year state and jumpToYear() already clears an active
"Année" filter when it runs, so keeping both would just be redundant.

mounted() applies compte/client/type/écarts unconditionally (they only
narrow filteredRows, safe regardless of load path), then branches:
annee in the hash sets filterYear (triggering enterYearMode() same as
manual use), aller calls jumpToYear() directly, otherwise the default
load()+scroll-to-bottom path runs as before -- each path already
manages its own loading state and scroll position, so only one runs.

Verified all three round-trip through reload: compte+type together
(ensureScrollable extends to find matches, as before), aller=2023
(first fetch targets exactly 2023-01-01..2024-06-30), and annee=2022
(every loaded row's data-year is 2022, filter dropdown reflects it).
Default no-hash load is unaffected.
This commit is contained in:
2026-09-05 11:17:33 +02:00
parent 9a70205c83
commit 0170ec4475
@@ -170,6 +170,40 @@
return res.json();
}
// URL hash (#compte=Maud&type=versement&annee=2023&ecarts=1&aller=2024)
// makes the filters and "Aller à" shareable/reloadable -- reload the
// page or send the link and you land back in the same filtered view.
// "aller" isn't an ongoing filter (jumpYearValue itself always resets
// to '' right after firing) but its target year is worth remembering
// for this purpose, so it's tracked separately (lastJumpYear) purely
// for the hash. Not included: groupBy, filterEntreeId -- out of scope
// of what was asked (the toolbar filters + "Aller à").
function readHashState() {
const params = new URLSearchParams(location.hash.replace(/^#/, ''));
return {
filterCompte: params.get('compte') || '',
filterClient: params.get('client') || '',
filterType: params.get('type') || '',
filterYear: params.get('annee') || '',
onlyErrors: params.get('ecarts') === '1',
aller: params.get('aller') || '',
};
}
function buildHashString(state) {
const params = new URLSearchParams();
if (state.filterCompte) params.set('compte', state.filterCompte);
if (state.filterClient) params.set('client', state.filterClient);
if (state.filterType) params.set('type', state.filterType);
if (state.filterYear) params.set('annee', state.filterYear);
if (state.onlyErrors) params.set('ecarts', '1');
// Redundant/ambiguous alongside an active "Année" filter -- that
// already fully describes the year, so don't also carry a stale
// "aller" target into the hash.
if (state.lastJumpYear && !state.filterYear) params.set('aller', state.lastJumpYear);
return params.toString();
}
function resolve(includedMap, ref) {
if (!ref) return null;
return includedMap.get(ref.type + ':' + ref.id) || null;
@@ -233,6 +267,10 @@
filterType: '',
filterYear: '',
jumpYearValue: '',
// Not a filter itself (jumpYearValue always resets to '' right
// after firing) -- just remembers the last "Aller à" target so
// the URL hash can reflect it. See syncHash()/readHashState().
lastJumpYear: '',
groupBy: 'month',
onlyErrors: false,
hoverCol: null,
@@ -887,6 +925,8 @@
this._skipYearWatch = true;
this.filterYear = '';
}
this.lastJumpYear = year;
this.syncHash();
this.loading = true;
this.error = null;
try {
@@ -907,6 +947,22 @@
this.detectCurrentYear();
this.checkEdges();
},
// Reflects the current filters + "Aller à" target in the URL hash
// (replacing, not pushing, so tweaking a dropdown doesn't spam the
// back button with history entries) -- reload the page or share the
// link and readHashState()/mounted() reproduce the same view.
syncHash() {
const hash = buildHashString({
filterCompte: this.filterCompte,
filterClient: this.filterClient,
filterType: this.filterType,
filterYear: this.filterYear,
onlyErrors: this.onlyErrors,
lastJumpYear: this.lastJumpYear,
});
const url = location.pathname + location.search + (hash ? '#' + hash : '');
history.replaceState(null, '', url);
},
},
watch: {
filterYear(newYear, oldYear) {
@@ -919,43 +975,70 @@
} else if (oldYear) {
this.exitYearMode();
}
this.syncHash();
},
// Any of these can thin filteredRows enough to remove the
// scrollbar the sliding window relies on to keep loading -- see
// ensureScrollable(). filterYear is handled separately above (it
// swaps the whole loading strategy, not just the visible subset).
// Each also reflects into the URL hash so the filtered view is
// reloadable/shareable -- see syncHash().
filterCompte() {
this.ensureScrollable();
this.syncHash();
},
filterClient() {
this.ensureScrollable();
this.syncHash();
},
filterType() {
this.ensureScrollable();
this.syncHash();
},
onlyErrors() {
this.ensureScrollable();
this.syncHash();
},
},
async mounted() {
await this.load();
// Reproduce whatever the URL hash describes -- reload or a shared
// link should land on the same filtered view. The four basic
// filters just narrow filteredRows, so they're safe to set before
// deciding how to load; filterYear/aller each own their loading
// path (enterYearMode()/jumpToYear()), so at most one of those
// runs instead of the default load().
const hashState = readHashState();
this.filterCompte = hashState.filterCompte;
this.filterClient = hashState.filterClient;
this.filterType = hashState.filterType;
this.onlyErrors = hashState.onlyErrors;
if (hashState.filterYear) {
this.filterYear = hashState.filterYear;
} else if (hashState.aller) {
this.lastJumpYear = hashState.aller;
this.jumpToYear(hashState.aller);
} else {
await this.load();
await this.$nextTick();
const wrap = this.$refs.tableWrap;
this.ensureScrollListener();
if (wrap) {
// Start scrolled to the most recent data (the window is
// centered on "today", so that's the bottom of what's loaded).
wrap.scrollTop = wrap.scrollHeight;
}
this.detectCurrentYear();
this.checkEdges();
this.ensureScrollable();
}
// Independent of the row window, so the dropdowns don't shrink to
// "whatever happens to be loaded right now".
fetchClientNames().then((names) => { this.allClientsList = names; }).catch(() => {});
fetchYearsList().then((years) => { this.allYearsList = years; }).catch(() => {});
fetchOuvertureEcarts().then((ecarts) => { this.ouvertureEcarts = ecarts; }).catch(() => {});
jQuery(document).on('dialog:afterclose', () => this.reloadWindow());
await this.$nextTick();
const wrap = this.$refs.tableWrap;
this.ensureScrollListener();
if (wrap) {
// Start scrolled to the most recent data (the window is centered
// on "today", so that's the bottom of what's loaded).
wrap.scrollTop = wrap.scrollHeight;
}
this.detectCurrentYear();
this.checkEdges();
this.ensureScrollable();
},
};