fix(groups): assignation en lot 1 requête + trackBy (plus de wipe/429)

This commit is contained in:
2026-09-27 13:52:20 -04:00
parent 9b620aaab6
commit 63a2bddf47
3 changed files with 72 additions and 19 deletions
@@ -82,7 +82,7 @@
Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé. Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé.
</div> </div>
<ul *ngIf="suggestions().length > 0" class="mt-3 space-y-2"> <ul *ngIf="suggestions().length > 0" class="mt-3 space-y-2">
<li *ngFor="let s of suggestions()" class="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 sm:flex-row sm:items-center sm:justify-between"> <li *ngFor="let s of suggestions(); trackBy: trackBySuggestion" class="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-xl" [style.background-color]="s.color + '26'" [style.border]="'1px solid ' + s.color + '66'">{{ s.icon }}</span> <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-xl" [style.background-color]="s.color + '26'" [style.border]="'1px solid ' + s.color + '66'">{{ s.icon }}</span>
<div class="min-w-0"> <div class="min-w-0">
@@ -138,7 +138,7 @@
<div *ngIf="activeTab() === 'groups'"> <div *ngIf="activeTab() === 'groups'">
<div *ngIf="groups().length > 0; else noGroups" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div *ngIf="groups().length > 0; else noGroups" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div <div
*ngFor="let group of groups()" *ngFor="let group of groups(); trackBy: trackByGroupId"
(click)="openGroupChannel(group)" (click)="openGroupChannel(group)"
class="cursor-pointer rounded-xl border border-slate-800 bg-slate-900/60 p-4 shadow-lg shadow-black/30 transition hover:border-slate-600" class="cursor-pointer rounded-xl border border-slate-800 bg-slate-900/60 p-4 shadow-lg shadow-black/30 transition hover:border-slate-600"
> >
@@ -215,7 +215,7 @@
Tous Tous
</button> </button>
<button <button
*ngFor="let group of groups()" *ngFor="let group of groups(); trackBy: trackByGroupId"
type="button" type="button"
(click)="setGroupFilter(group.id)" (click)="setGroupFilter(group.id)"
class="rounded-full border px-3 py-1 text-xs font-semibold transition" class="rounded-full border px-3 py-1 text-xs font-semibold transition"
@@ -307,7 +307,7 @@
class="rounded-md border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-100" class="rounded-md border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-100"
> >
<option value="">— Choisir un groupe —</option> <option value="">— Choisir un groupe —</option>
<option *ngFor="let group of groups()" [value]="group.id">{{ group.icon }} {{ group.name }}</option> <option *ngFor="let group of groups(); trackBy: trackByGroupId" [value]="group.id">{{ group.icon }} {{ group.name }}</option>
</select> </select>
<button <button
type="button" type="button"
@@ -330,7 +330,7 @@
<ng-container *ngIf="filteredItems().length > 0; else emptyState"> <ng-container *ngIf="filteredItems().length > 0; else emptyState">
<div class="divide-y divide-slate-800"> <div class="divide-y divide-slate-800">
<div <div
*ngFor="let item of filteredItems()" *ngFor="let item of filteredItems(); trackBy: trackBySubscriptionId"
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between" class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
> >
<ng-container *ngIf="item.channel as channel; else subscriptionFallback"> <ng-container *ngIf="item.channel as channel; else subscriptionFallback">
@@ -404,7 +404,7 @@
<button type="button" (click)="openNewGroup()" class="text-red-300 hover:underline">Créer un groupe</button> <button type="button" (click)="openNewGroup()" class="text-red-300 hover:underline">Créer un groupe</button>
</div> </div>
<label <label
*ngFor="let group of groups()" *ngFor="let group of groups(); trackBy: trackByGroupId"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm text-slate-200 hover:bg-slate-800" class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm text-slate-200 hover:bg-slate-800"
> >
<input <input
@@ -474,7 +474,7 @@
<span class="text-xs text-slate-500">{{ items().length }}</span> <span class="text-xs text-slate-500">{{ items().length }}</span>
</button> </button>
</li> </li>
<li *ngFor="let group of groups()"> <li *ngFor="let group of groups(); trackBy: trackByGroupId">
<button <button
type="button" type="button"
(click)="setGroupFilter(group.id); setTab('channels')" (click)="setGroupFilter(group.id); setTab('channels')"
@@ -183,28 +183,34 @@ export class SubscriptionsComponent {
} }
} }
/** Crée le groupe suggéré (ou complète l'existant) et y assigne les chaînes. */ /** Crée le groupe suggéré (ou complète l'existant) : 2 requêtes max, pas de tempête. */
applySuggestion(s: GroupSuggestion): Promise<void> { applySuggestion(s: GroupSuggestion): Promise<void> {
if (this.suggestBusy()) return Promise.resolve(); if (this.suggestBusy()) return Promise.resolve();
this.suggestBusy.set(true); this.suggestBusy.set(true);
const assignAll = (groupId: string): Promise<void> => {
const calls = s.channelIds
.filter((sid) => !(this.members()[String(sid)] || []).includes(groupId))
.map((sid) => new Promise<void>((resolve) => {
const current = this.members()[String(sid)] || [];
this.subs.setSubscriptionGroups(sid, [...current, groupId]).subscribe({ next: () => resolve(), error: () => resolve() });
}));
return Promise.all(calls).then(() => undefined);
};
const done = () => this.suggestBusy.set(false); const done = () => this.suggestBusy.set(false);
const run = (gid: string): Promise<void> => new Promise<void>((resolve) => {
this.subs.setGroupMembers(gid, this.memberIdsOf(gid, s)).subscribe({ next: () => resolve(), error: () => resolve() });
});
if (s.kind === 'complete' && s.existingGroupId) { if (s.kind === 'complete' && s.existingGroupId) {
return assignAll(s.existingGroupId).finally(done); return run(s.existingGroupId).finally(done);
} }
return new Promise<void>((resolve) => { return new Promise<void>((resolve) => {
this.subs.createGroup(s.name, s.color, s.icon).subscribe((g) => resolve(g?.id ? assignAll(g.id) : undefined)); this.subs.createGroup(s.name, s.color, s.icon).subscribe((g) => {
if (g?.id) run(g.id).then(() => resolve());
else resolve();
});
}).finally(done); }).finally(done);
} }
/** Ids déjà dans le groupe + nouveaux (union), pour l'assignation en lot. */
private memberIdsOf(groupId: string, s: GroupSuggestion): number[] {
const members = this.members();
const current = Object.keys(members)
.filter((k) => (members[k] || []).includes(groupId))
.map(Number);
return [...new Set([...current, ...s.channelIds])];
}
/** Applique toutes les suggestions d'un coup, dans l'ordre. */ /** Applique toutes les suggestions d'un coup, dans l'ordre. */
async applyAllSuggestions(): Promise<void> { async applyAllSuggestions(): Promise<void> {
if (this.suggestBusy()) return; if (this.suggestBusy()) return;
@@ -213,6 +219,19 @@ export class SubscriptionsComponent {
} }
} }
// --- trackBy : DOM stable (pas de re-rendu complet + recharge d'images à chaque tick) ---
trackBySubscriptionId(_idx: number, item: SubscriptionItem): number {
return item.subscriptionId;
}
trackByGroupId(_idx: number, group: SubscriptionGroup): string {
return group.id;
}
trackBySuggestion(_idx: number, s: GroupSuggestion): string {
return `${s.kind}:${s.existingGroupId || s.name}`;
}
readonly items = computed(() => this.subs.items()); readonly items = computed(() => this.subs.items());
readonly groups = computed(() => this.subs.subscriptionGroups()); readonly groups = computed(() => this.subs.subscriptionGroups());
readonly members = computed(() => this.subs.groupMembers()); readonly members = computed(() => this.subs.groupMembers());
+34
View File
@@ -546,6 +546,40 @@ export class SubscriptionsService {
); );
} }
/**
* Remplace les chaînes d'un groupe en UNE seule requête (assignation en lot).
* Évite la tempête de PUT par chaîne (rate-limit + re-rendus en cascade).
*/
setGroupMembers(groupId: string, subscriptionIds: number[]): Observable<boolean> {
const ids = [...new Set((subscriptionIds || []).map(Number).filter(Number.isFinite))];
return this.http.put(`/api/subscription-groups/${groupId}/members`, { subscriptionIds: ids }).pipe(
tap(() => {
const wanted = new Set(ids.map(String));
const members = { ...this.members() };
for (const key of Object.keys(members)) {
const cur = members[key] || [];
const has = cur.includes(groupId);
const want = wanted.has(key);
if (has === want) continue;
members[key] = want ? [...cur, groupId] : cur.filter((gid) => gid !== groupId);
}
for (const sid of wanted) {
const key = String(sid);
if (!(key in members)) members[key] = [groupId];
}
this.members.set(members);
this.refreshGroupCounts();
}),
map(() => true),
catchError((err) => {
console.error('Failed to set group members', err);
this.lastErrorMsg.set('Assignation en lot impossible. Réessayez.');
this.listGroups().subscribe();
return of(false);
})
);
}
/** Recalcule les compteurs locaux sans appel réseau. */ /** Recalcule les compteurs locaux sans appel réseau. */
private refreshGroupCounts(): void { private refreshGroupCounts(): void {
const members = this.members(); const members = this.members();