fix(groups): assignation en lot 1 requête + trackBy (plus de wipe/429)
This commit is contained in:
@@ -82,7 +82,7 @@
|
||||
Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé.
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
@@ -138,7 +138,7 @@
|
||||
<div *ngIf="activeTab() === 'groups'">
|
||||
<div *ngIf="groups().length > 0; else noGroups" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div
|
||||
*ngFor="let group of groups()"
|
||||
*ngFor="let group of groups(); trackBy: trackByGroupId"
|
||||
(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"
|
||||
>
|
||||
@@ -215,7 +215,7 @@
|
||||
Tous
|
||||
</button>
|
||||
<button
|
||||
*ngFor="let group of groups()"
|
||||
*ngFor="let group of groups(); trackBy: trackByGroupId"
|
||||
type="button"
|
||||
(click)="setGroupFilter(group.id)"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -330,7 +330,7 @@
|
||||
<ng-container *ngIf="filteredItems().length > 0; else emptyState">
|
||||
<div class="divide-y divide-slate-800">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<input
|
||||
@@ -474,7 +474,7 @@
|
||||
<span class="text-xs text-slate-500">{{ items().length }}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li *ngFor="let group of groups()">
|
||||
<li *ngFor="let group of groups(); trackBy: trackByGroupId">
|
||||
<button
|
||||
type="button"
|
||||
(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> {
|
||||
if (this.suggestBusy()) return Promise.resolve();
|
||||
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 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) {
|
||||
return assignAll(s.existingGroupId).finally(done);
|
||||
return run(s.existingGroupId).finally(done);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
async applyAllSuggestions(): Promise<void> {
|
||||
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 groups = computed(() => this.subs.subscriptionGroups());
|
||||
readonly members = computed(() => this.subs.groupMembers());
|
||||
|
||||
@@ -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. */
|
||||
private refreshGroupCounts(): void {
|
||||
const members = this.members();
|
||||
|
||||
Reference in New Issue
Block a user