diff --git a/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx b/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx index 83a96bd..d19ead5 100644 --- a/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx +++ b/apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx @@ -18,6 +18,7 @@ const messages: Record> = { 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.', + noSearchResults: 'Kein Treffer für "{search}" — weder bei den Modulen noch bei den Gruppen.', }, 'admin.groups.grants': { matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}', @@ -93,6 +94,7 @@ const mockMatrix = { groups: [ { id: 'g1', name: 'Alle Benutzer' }, { id: 'g2', name: 'Buchhaltung' }, + { id: 'g3', name: 'Claude_VT', internalName: 'Vertrieb' }, ], grants: [{ moduleId: 'm1', groupId: 'g1' }], }; @@ -203,6 +205,102 @@ describe('AdminModuleGrantsPage (Permission-Matrix)', () => { 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(); + + 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(); + + 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(); + + 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(); + + 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 () => { vi.stubGlobal( 'fetch', diff --git a/apps/web/src/app/(portal)/admin/modules/grants/page.tsx b/apps/web/src/app/(portal)/admin/modules/grants/page.tsx index a271cb9..0891d57 100644 --- a/apps/web/src/app/(portal)/admin/modules/grants/page.tsx +++ b/apps/web/src/app/(portal)/admin/modules/grants/page.tsx @@ -133,19 +133,33 @@ export default function AdminModuleGrantsPage() { const searchLower = search.trim().toLowerCase(); const matches = (text: string) => searchLower === '' || text.toLowerCase().includes(searchLower); - const filteredModules = useMemo( - () => modules.filter((m) => matches(m.name)), - // eslint-disable-next-line react-hooks/exhaustive-deps - [modules, searchLower], - ); - const filteredGroups = useMemo( + // Both axes are searched independently, then combined with the following + // semantics (WINDOWS #14): a term that hits only one axis must not empty + // the other axis, or no clickable cell ever remains. See in + // 260909-ab3-PLAN.md for the full truth table. + const { filteredModules, filteredGroups, noMatch } = 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 // AD name — a search for either the internal or the AD-original name // 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 - [groups, searchLower], - ); + }, [modules, groups, searchLower]); const groupedModules = useMemo(() => { 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" /> + {noMatch ? ( +
+

{t('noSearchResults', { search })}

+
+ ) : (
@@ -274,6 +293,7 @@ export default function AdminModuleGrantsPage() {
+ )}

{t('adminNote')}

diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 06a41b4..a7b5f97 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -457,7 +457,8 @@ "emptyModules": "Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.", "emptyModulesLink": "Zu Module", "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": { "title": "Modul aktivieren: {module}", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 536f921..a4529a4 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -457,7 +457,8 @@ "emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.", "emptyModulesLink": "Go to Modules", "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": { "title": "Activate Module: {module}",