feat(tickets): persistance des filtres tickets entre redémarrages (#29)
Introduit le port UiPreferencesGateway et son adapter uiPreferences, avec le module ticketFilterPersistence qui sauvegarde/restaure les filtres (recherche, statut, sprint, agents) via useTickets, useTicketSearch et useProjectAgents. Couvert par tests unitaires et un test d'intégration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState";
|
||||
import { TauriConversationGateway } from "./conversation";
|
||||
import { TauriTicketGateway } from "./ticket";
|
||||
import { TauriWindowGateway } from "./window";
|
||||
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
|
||||
|
||||
function notImplemented(what: string): never {
|
||||
const err: GatewayError = {
|
||||
@ -67,6 +68,7 @@ export function createTauriGateways(): Gateways {
|
||||
conversation: new TauriConversationGateway(),
|
||||
ticket: new TauriTicketGateway(),
|
||||
window: new TauriWindowGateway(),
|
||||
uiPreferences: new LocalStorageUiPreferencesGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -89,4 +91,5 @@ export {
|
||||
TauriConversationGateway,
|
||||
TauriTicketGateway,
|
||||
TauriWindowGateway,
|
||||
LocalStorageUiPreferencesGateway,
|
||||
};
|
||||
|
||||
@ -88,6 +88,7 @@ import type {
|
||||
TicketListQuery,
|
||||
CreateTicketInput,
|
||||
UpdateTicketInput,
|
||||
UiPreferencesGateway,
|
||||
ViewWindowClosed,
|
||||
WindowGateway,
|
||||
WorkStateGateway,
|
||||
@ -2529,6 +2530,38 @@ export class MockTicketGateway implements TicketGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link UiPreferencesGateway} for tests/dev (ticket #29). Mirrors the
|
||||
* localStorage adapter's best-effort JSON round-trip without touching the DOM,
|
||||
* so persisted-filter behaviour can be exercised offline.
|
||||
*/
|
||||
export class MockUiPreferencesGateway implements UiPreferencesGateway {
|
||||
private readonly store = new Map<string, string>();
|
||||
|
||||
read(key: string): unknown {
|
||||
const raw = this.store.get(key);
|
||||
if (raw === undefined) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
this.store.delete(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
write(key: string, value: unknown): void {
|
||||
try {
|
||||
this.store.set(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
remove(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function mostRestrictive(
|
||||
project?: PermissionSet["fallback"],
|
||||
agent?: PermissionSet["fallback"],
|
||||
@ -2563,6 +2596,7 @@ export function createMockGateways(): Gateways {
|
||||
conversation: new MockConversationGateway(),
|
||||
ticket: new MockTicketGateway(systemGateway),
|
||||
window: new MockWindowGateway(),
|
||||
uiPreferences: new MockUiPreferencesGateway(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ describe("createMockGateways", () => {
|
||||
"template",
|
||||
"terminal",
|
||||
"ticket",
|
||||
"uiPreferences",
|
||||
"window",
|
||||
"workState",
|
||||
]);
|
||||
|
||||
65
frontend/src/adapters/uiPreferences.ts
Normal file
65
frontend/src/adapters/uiPreferences.ts
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* `localStorage`-backed {@link UiPreferencesGateway} (ticket #29).
|
||||
*
|
||||
* The only place that touches `window.localStorage`; features go through the
|
||||
* port via DI. Everything is best-effort: a missing / private-mode / quota-full
|
||||
* storage, or a corrupt JSON entry, degrades silently to "no preference" rather
|
||||
* than throwing into the UI (persisted UI filters must never break the app).
|
||||
*/
|
||||
|
||||
import type { UiPreferencesGateway } from "@/ports";
|
||||
|
||||
/** Returns the `Storage` if usable in this environment, else `null`. */
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return typeof window !== "undefined" ? window.localStorage : null;
|
||||
} catch {
|
||||
// Accessing `localStorage` can throw (sandboxed / disabled cookies).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalStorageUiPreferencesGateway implements UiPreferencesGateway {
|
||||
read(key: string): unknown {
|
||||
const store = storage();
|
||||
if (!store) return null;
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = store.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
// Corrupt entry — drop it so we stop tripping over it, return default.
|
||||
try {
|
||||
store.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
write(key: string, value: unknown): void {
|
||||
const store = storage();
|
||||
if (!store) return;
|
||||
try {
|
||||
store.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
/* quota / serialisation failure — persistence is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
remove(key: string): void {
|
||||
const store = storage();
|
||||
if (!store) return;
|
||||
try {
|
||||
store.removeItem(key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user