fix(quick-260909-ab3): Matrix-Suche filtert nur noch die getroffene Achse

- Gemeinsamer useMemo ermittelt moduleHits/groupHits getrennt; trifft der
  Suchbegriff nur eine Achse, bleibt die andere vollstaendig sichtbar
  (WINDOWS #14) statt leerzulaufen
- Regressionsschutz fuer WINDOWS #6c erhalten: Spaltensuche findet eine
  importierte Gruppe weiterhin unter internem UND AD-Namen
- Neue Meldung adminModules.grants.noSearchResults, wenn ein Begriff weder
  Modul noch Gruppe trifft — Tabelle wird dann nicht gerendert
- Vier neue Testfaelle in grants-matrix.test.tsx vorab gegen den
  unveraenderten Bestand rot gelaufen (erwartete Ursachen bestaetigt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FYZcd3SSmo14QTqWx2KKzU
This commit is contained in:
2026-09-09 07:43:21 +02:00
parent dbeab8c38a
commit e8c2411e60
4 changed files with 131 additions and 11 deletions
@@ -18,6 +18,7 @@ const messages: Record<string, Record<string, unknown>> = {
adminNote: adminNote:
'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.', 'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.', saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
noSearchResults: 'Kein Treffer für "{search}" — weder bei den Modulen noch bei den Gruppen.',
}, },
'admin.groups.grants': { 'admin.groups.grants': {
matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}', matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}',
@@ -93,6 +94,7 @@ const mockMatrix = {
groups: [ groups: [
{ id: 'g1', name: 'Alle Benutzer' }, { id: 'g1', name: 'Alle Benutzer' },
{ id: 'g2', name: 'Buchhaltung' }, { id: 'g2', name: 'Buchhaltung' },
{ id: 'g3', name: 'Claude_VT', internalName: 'Vertrieb' },
], ],
grants: [{ moduleId: 'm1', groupId: 'g1' }], grants: [{ moduleId: 'm1', groupId: 'g1' }],
}; };
@@ -203,6 +205,102 @@ describe('AdminModuleGrantsPage (Permission-Matrix)', () => {
expect(screen.getByText('DKV Flotte')).toBeInTheDocument(); expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
}); });
it('a module-name search leaves all group columns standing (WINDOWS #14)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
);
render(<AdminModuleGrantsPage />);
await waitFor(() => {
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Module oder Gruppen durchsuchen...');
await userEvent.type(searchInput, 'Buchhaltung');
await waitFor(() => {
expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument();
});
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
expect(screen.queryByText('Alle Benutzer')).not.toBeInTheDocument();
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0);
});
it('a group-name search leaves all module rows standing (WINDOWS #14)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
);
render(<AdminModuleGrantsPage />);
await waitFor(() => {
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Module oder Gruppen durchsuchen...');
await userEvent.type(searchInput, 'Flotte');
await waitFor(() => {
expect(screen.getByText('Alle Benutzer')).toBeInTheDocument();
});
expect(screen.getByText('Buchhaltung')).toBeInTheDocument();
expect(screen.queryByText('Ausschreibungs-Radar')).not.toBeInTheDocument();
});
it('finds an imported group by both internal and AD name (WINDOWS #6c regression)', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
);
render(<AdminModuleGrantsPage />);
await waitFor(() => {
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Module oder Gruppen durchsuchen...');
await userEvent.type(searchInput, 'Vertrieb');
await waitFor(() => {
expect(screen.getByText('Vertrieb')).toBeInTheDocument();
});
expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument();
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
await userEvent.clear(searchInput);
await userEvent.type(searchInput, 'Claude_VT');
await waitFor(() => {
expect(screen.getByText('Vertrieb')).toBeInTheDocument();
});
expect(screen.getByText('Ausschreibungs-Radar')).toBeInTheDocument();
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
});
it('shows a visible message when the search term matches neither modules nor groups', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockMatrix) })),
);
render(<AdminModuleGrantsPage />);
await waitFor(() => {
expect(screen.getByText('DKV Flotte')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Module oder Gruppen durchsuchen...');
await userEvent.type(searchInput, 'zzz');
await waitFor(() => {
expect(screen.getByText(/Kein Treffer für "zzz"/)).toBeInTheDocument();
});
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('gives every matrix checkbox an aria-label', async () => { it('gives every matrix checkbox an aria-label', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
@@ -133,19 +133,33 @@ export default function AdminModuleGrantsPage() {
const searchLower = search.trim().toLowerCase(); const searchLower = search.trim().toLowerCase();
const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower); const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower);
const filteredModules = useMemo( // Both axes are searched independently, then combined with the following
() => modules.filter((m) => matches(m.name)), // semantics (WINDOWS #14): a term that hits only one axis must not empty
// eslint-disable-next-line react-hooks/exhaustive-deps // the other axis, or no clickable cell ever remains. See <behavior> in
[modules, searchLower], // 260909-ab3-PLAN.md for the full truth table.
); const { filteredModules, filteredGroups, noMatch } = useMemo(() => {
const filteredGroups = useMemo( if (searchLower === '') {
return { filteredModules: modules, filteredGroups: groups, noMatch: false };
}
const moduleHits = modules.filter((m) => matches(m.name));
// Matches both the display name (internalName ?? name) and the stored // Matches both the display name (internalName ?? name) and the stored
// AD name — a search for either the internal or the AD-original name // AD name — a search for either the internal or the AD-original name
// must find the group (D-04). // must find the group (D-04).
() => groups.filter((g) => matches(g.internalName ?? g.name) || matches(g.name)), const groupHits = groups.filter((g) => matches(g.internalName ?? g.name) || matches(g.name));
if (moduleHits.length === 0 && groupHits.length === 0) {
return { filteredModules: [], filteredGroups: [], noMatch: true };
}
if (moduleHits.length > 0 && groupHits.length === 0) {
return { filteredModules: moduleHits, filteredGroups: groups, noMatch: false };
}
if (groupHits.length > 0 && moduleHits.length === 0) {
return { filteredModules: modules, filteredGroups: groupHits, noMatch: false };
}
return { filteredModules: moduleHits, filteredGroups: groupHits, noMatch: false };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[groups, searchLower], }, [modules, groups, searchLower]);
);
const groupedModules = useMemo(() => { const groupedModules = useMemo(() => {
const result: { category: string; mods: Module[] }[] = []; const result: { category: string; mods: Module[] }[] = [];
@@ -202,6 +216,11 @@ export default function AdminModuleGrantsPage() {
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm" className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
/> />
{noMatch ? (
<div className="rounded-md border border-border bg-card p-6 text-center">
<p className="text-sm text-muted-foreground">{t('noSearchResults', { search })}</p>
</div>
) : (
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border"> <div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border">
<table className="w-full text-sm border-collapse"> <table className="w-full text-sm border-collapse">
<thead> <thead>
@@ -274,6 +293,7 @@ export default function AdminModuleGrantsPage() {
</tbody> </tbody>
</table> </table>
</div> </div>
)}
<p className="text-xs text-muted-foreground">{t('adminNote')}</p> <p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</> </>
+2 -1
View File
@@ -457,7 +457,8 @@
"emptyModules": "Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.", "emptyModules": "Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.",
"emptyModulesLink": "Zu Module", "emptyModulesLink": "Zu Module",
"adminNote": "ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.", "adminNote": "ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen." "saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
}, },
"activationDialog": { "activationDialog": {
"title": "Modul aktivieren: {module}", "title": "Modul aktivieren: {module}",
+2 -1
View File
@@ -457,7 +457,8 @@
"emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.", "emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.",
"emptyModulesLink": "Go to Modules", "emptyModulesLink": "Go to Modules",
"adminNote": "ADMIN and SUPER_ADMIN always have access to all active modules — this matrix only applies to the USER role.", "adminNote": "ADMIN and SUPER_ADMIN always have access to all active modules — this matrix only applies to the USER role.",
"saveError": "Could not save grant. Please try again." "saveError": "Could not save grant. Please try again.",
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
}, },
"activationDialog": { "activationDialog": {
"title": "Activate Module: {module}", "title": "Activate Module: {module}",