fix(web): Absicht/Ballast der letzten sechs useExhaustiveDependencies-Befunde entschieden
Befunde 16-21 (quick-260921-gof), Regel-Gesamtstand jetzt 0/446 (vorher 21/467), pnpm lint 5/5, pnpm type-check 4/4: - InvoiceHistoryTable.tsx (16) und sidebar.tsx (17): refreshKey / sidebarRefreshKey bleiben als begruendete Auffrisch-Ausloeser stehen (biome-ignore mit deutschem Grund) - ohne sie zeigt die DKV-Historie nach "Jetzt pruefen" bzw. die Seitenleiste nach einer Modul- Aktivierung den alten Stand. - ActivateModuleDialog.tsx (18): moduleId aus der Abhaengigkeitsliste entfernt - reiner Ballast, der Effekt holt ohnehin nur die vom Modul unabhaengige Gruppenliste und die Elternseite haengt den Dialog je Modul frisch ein. - GroupMembersModal.tsx (19/20): fetchMembers/fetchAllUsers in die Liste aufgenommen (echter Defekt) - ohne sie zeigt der Dialog bei einem Gruppenwechsel ohne Neuaufbau die Mitglieder der vorigen Gruppe. Neue Testdatei nach dem grants-matrix.test.tsx-Muster belegt genau diesen Fall. - grants/page.tsx (21): die Suchabgleich-Hilfsfunktion `matches` in den Merkungs-Rumpf verschoben statt im Bauteil-Rumpf zu bleiben - reiner Ballast, die bestehenden Suchfaelle bleiben unveraendert gruen. - sidebar.test.tsx um eine Zaehlprobe erweitert: Bump loest genau einen weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen. - Alle drei begruendeten biome-ignore-Zeilen sind jetzt gesetzt (erste Verwendung dieses Mechanismus im Projekt), alle wirkungslosen eslint-disable-Zeilen fuer diese Regel sind aus apps/web/src verschwunden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Group } from '../page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GroupMembersModal.test — quick-260921-gof, Befunde 19/20.
|
||||||
|
*
|
||||||
|
* Mirrors the grants-matrix.test.tsx pattern for this admin surface: a
|
||||||
|
* global `fetch` stub keyed by URL, next-intl mocked with a flat
|
||||||
|
* key -> German-copy lookup table. Text is hand-written here, NOT derived
|
||||||
|
* from the component under test.
|
||||||
|
*
|
||||||
|
* Der entscheidende Fall ist der letzte: ein Wechsel der uebergebenen
|
||||||
|
* Gruppe (ohne Neuaufbau der Komponente — die Elternseite haengt diesen
|
||||||
|
* Dialog nicht per key neu ein) muss die Mitglieder der neuen Gruppe holen.
|
||||||
|
* Vor Befund 19/20 waere das die Falle: der Mount-Effekt haette leer
|
||||||
|
* ([]) als Abhaengigkeitsliste gefuehrt und beim Gruppenwechsel gar nicht
|
||||||
|
* neu geladen — der Dialog haette die Mitglieder der vorigen Gruppe
|
||||||
|
* gezeigt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const messages: Record<string, string> = {
|
||||||
|
'members.title': 'Mitglieder',
|
||||||
|
'members.noMembers': 'Keine Mitglieder',
|
||||||
|
'members.addTitle': 'Mitglied hinzufügen',
|
||||||
|
'members.searchPlaceholder': 'Benutzer suchen...',
|
||||||
|
'members.remove': 'Entfernen',
|
||||||
|
'members.ldapManaged': 'Wird per AD verwaltet',
|
||||||
|
'members.sourceLdap': 'AD',
|
||||||
|
'members.sourceManual': 'Manuell',
|
||||||
|
'members.addError': 'Mitglied konnte nicht hinzugefügt werden',
|
||||||
|
loading: 'Laden...',
|
||||||
|
close: 'Schließen',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => messages[key] ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { GroupMembersModal } from './GroupMembersModal';
|
||||||
|
|
||||||
|
function group(overrides: Partial<Group> = {}): Group {
|
||||||
|
return {
|
||||||
|
id: 'g1',
|
||||||
|
tenantId: 't1',
|
||||||
|
name: 'Buchhaltung',
|
||||||
|
ldapDn: null,
|
||||||
|
internalName: null,
|
||||||
|
isDefault: false,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: '',
|
||||||
|
memberCount: 1,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMBERS_G1 = [
|
||||||
|
{
|
||||||
|
id: 'mem-1',
|
||||||
|
groupId: 'g1',
|
||||||
|
userId: 'u1',
|
||||||
|
source: 'MANUAL',
|
||||||
|
createdAt: '',
|
||||||
|
user: { id: 'u1', username: 'anna', displayName: 'Anna Schmidt', email: 'anna@example.com' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const MEMBERS_G2 = [
|
||||||
|
{
|
||||||
|
id: 'mem-2',
|
||||||
|
groupId: 'g2',
|
||||||
|
userId: 'u2',
|
||||||
|
source: 'MANUAL',
|
||||||
|
createdAt: '',
|
||||||
|
user: { id: 'u2', username: 'bernd', displayName: 'Bernd Mueller', email: 'bernd@example.com' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_USERS = [
|
||||||
|
{ id: 'u1', username: 'anna', displayName: 'Anna Schmidt', email: 'anna@example.com' },
|
||||||
|
{ id: 'u2', username: 'bernd', displayName: 'Bernd Mueller', email: 'bernd@example.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function stubFetch() {
|
||||||
|
const fetchMock = vi.fn((url: string) => {
|
||||||
|
if (typeof url === 'string' && url.endsWith('/groups/g1/members')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(MEMBERS_G1) });
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && url.endsWith('/groups/g2/members')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(MEMBERS_G2) });
|
||||||
|
}
|
||||||
|
if (typeof url === 'string' && url.endsWith('/users')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(ALL_USERS) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
return fetchMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GroupMembersModal', () => {
|
||||||
|
it('einhaengen ergibt genau einen Mitglieder- und einen Benutzer-Abruf', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
|
||||||
|
render(<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberCalls = fetchMock.mock.calls.filter(([url]) =>
|
||||||
|
String(url).endsWith('/groups/g1/members'),
|
||||||
|
);
|
||||||
|
const userCalls = fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/users'));
|
||||||
|
expect(memberCalls).toHaveLength(1);
|
||||||
|
expect(userCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erneutes Zeichnen mit derselben Gruppe loest KEINE weiteren Abrufe aus', async () => {
|
||||||
|
const fetchMock = stubFetch();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const callCountBefore = fetchMock.mock.calls.length;
|
||||||
|
|
||||||
|
rerender(<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.length).toBe(callCountBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Befund 19/20: ein Wechsel der uebergebenen Gruppe (ohne Neuaufbau) loest genau einen weiteren Mitglieder-Abruf mit der neuen Kennung aus und zeigt die Mitglieder der neuen Gruppe', async () => {
|
||||||
|
stubFetch();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<GroupMembersModal group={group()} onClose={() => {}} onChanged={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(screen.getByRole('list')).getByText('Anna Schmidt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gleiche Komponenteninstanz, neue Gruppe — genau der Fall, den die
|
||||||
|
// Elternseite (admin/groups/page.tsx) erzeugt: kein `key`-Prop, also
|
||||||
|
// kein Neuaufbau.
|
||||||
|
rerender(
|
||||||
|
<GroupMembersModal
|
||||||
|
group={group({ id: 'g2', name: 'Vertrieb' })}
|
||||||
|
onClose={() => {}}
|
||||||
|
onChanged={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(within(screen.getByRole('list')).getByText('Bernd Mueller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(within(screen.getByRole('list')).queryByText('Anna Schmidt')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,12 +86,15 @@ export function GroupMembersModal({ group, onClose, onChanged }: GroupMembersMod
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Befunde 19/20 (quick-260921-gof): beide Ladefunktionen sind bereits
|
||||||
|
// stabil eingepackt (fetchMembers haengt nur an group.id, fetchAllUsers an
|
||||||
|
// nichts) — sie in die Liste aufzunehmen ist gefahrlos. Ohne sie zeigt der
|
||||||
|
// Dialog bei einem Gruppenwechsel ohne Neuaufbau (die Elternseite haengt
|
||||||
|
// diesen Dialog nicht per key neu ein) die Mitglieder der vorigen Gruppe.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
fetchAllUsers();
|
fetchAllUsers();
|
||||||
// group.id is stable for the lifetime of this modal instance.
|
}, [fetchMembers, fetchAllUsers]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]);
|
const memberUserIds = useMemo(() => new Set(members?.map((m) => m.userId) ?? []), [members]);
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export function ActivateModuleDialog({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, moduleId]);
|
// Befund 18 (quick-260921-gof): moduleId entfernt — der Effekt holt
|
||||||
|
// ohnehin nur die vom Modul unabhaengige Gruppenliste, und die
|
||||||
|
// Elternseite haengt diesen Dialog innerhalb einer Bedingung je
|
||||||
|
// gewaehltem Modul frisch ein (open dort immer fest true) — moduleId
|
||||||
|
// kann sich waehrend der Lebenszeit des Dialogs also nicht aendern.
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -131,13 +131,18 @@ export default function AdminModuleGrantsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchLower = search.trim().toLowerCase();
|
const searchLower = search.trim().toLowerCase();
|
||||||
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
|
|
||||||
|
|
||||||
// Both axes are searched independently, then combined with the following
|
// Both axes are searched independently, then combined with the following
|
||||||
// semantics (WINDOWS #14): a term that hits only one axis must not empty
|
// semantics (WINDOWS #14): a term that hits only one axis must not empty
|
||||||
// the other axis, or no clickable cell ever remains. See <behavior> in
|
// the other axis, or no clickable cell ever remains. See <behavior> in
|
||||||
// 260909-ab3-PLAN.md for the full truth table.
|
// 260909-ab3-PLAN.md for the full truth table.
|
||||||
|
//
|
||||||
|
// Befund 21 (quick-260921-gof): `matches` lebt jetzt im Rumpf der Merkung
|
||||||
|
// selbst statt im Bauteil-Rumpf — sie las bereits nur searchLower, das
|
||||||
|
// schon in der Liste steht; verschoben wird nur, WO die Funktion steht.
|
||||||
const { filteredModules, filteredGroups, noMatch } = useMemo(() => {
|
const { filteredModules, filteredGroups, noMatch } = useMemo(() => {
|
||||||
|
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
|
||||||
|
|
||||||
if (searchLower === '') {
|
if (searchLower === '') {
|
||||||
return { filteredModules: modules, filteredGroups: groups, noMatch: false };
|
return { filteredModules: modules, filteredGroups: groups, noMatch: false };
|
||||||
}
|
}
|
||||||
@@ -158,7 +163,6 @@ export default function AdminModuleGrantsPage() {
|
|||||||
return { filteredModules: modules, filteredGroups: groupHits, noMatch: false };
|
return { filteredModules: modules, filteredGroups: groupHits, noMatch: false };
|
||||||
}
|
}
|
||||||
return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false };
|
return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [modules, groups, searchLower]);
|
}, [modules, groups, searchLower]);
|
||||||
|
|
||||||
const groupedModules = useMemo(() => {
|
const groupedModules = useMemo(() => {
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ export function InvoiceHistoryTable({
|
|||||||
}
|
}
|
||||||
}, [onItemsLoaded]);
|
}, [onItemsLoaded]);
|
||||||
|
|
||||||
|
// Befund 16 (quick-260921-gof): refreshKey ist der Auffrisch-Ausloeser der
|
||||||
|
// Elternseite nach "Jetzt pruefen" — ohne ihn bliebe die Historie auf dem
|
||||||
|
// alten Stand stehen.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey ist der Auffrisch-Ausloeser der Elternseite nach "Jetzt pruefen" - ohne ihn bliebe die Historie auf dem alten Stand.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(1);
|
load(1);
|
||||||
}, [load, refreshKey]);
|
}, [load, refreshKey]);
|
||||||
|
|||||||
@@ -185,6 +185,29 @@ describe('Sidebar', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-260921-gof (Befund 17): ein Bump von sidebarRefreshKey loest genau einen weiteren Abruf aus, erneutes Zeichnen ohne Bump keinen', async () => {
|
||||||
|
const Sidebar = await importSidebar();
|
||||||
|
const { rerender } = render(<Sidebar />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Erneutes Zeichnen ohne Bump des Auffrisch-Zaehlers loest KEINEN
|
||||||
|
// weiteren Abruf aus.
|
||||||
|
rerender(<Sidebar />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Bump loest genau einen weiteren Abruf aus.
|
||||||
|
mockRefreshKey = 1;
|
||||||
|
rerender(<Sidebar />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the version badge below the navigation', async () => {
|
it('renders the version badge below the navigation', async () => {
|
||||||
const Sidebar = await importSidebar();
|
const Sidebar = await importSidebar();
|
||||||
render(<Sidebar />);
|
render(<Sidebar />);
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Befund 17 (quick-260921-gof): sidebarRefreshKey ist der Auffrisch-
|
||||||
|
// Ausloeser aus dem Marketplace-Speicher — ohne ihn erscheint ein frisch
|
||||||
|
// aktiviertes Modul in der Seitenleiste erst nach einem Neuladen der
|
||||||
|
// Seite, die Navigation liefe also dem Berechtigungsstand hinterher.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: sidebarRefreshKey ist der Auffrisch-Ausloeser aus dem Marketplace-Speicher - ohne ihn liefe die Navigation dem Berechtigungsstand hinterher.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchActiveModules();
|
fetchActiveModules();
|
||||||
}, [fetchActiveModules, sidebarRefreshKey]);
|
}, [fetchActiveModules, sidebarRefreshKey]);
|
||||||
|
|||||||
Reference in New Issue
Block a user