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:
@@ -18,6 +18,7 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
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(<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 () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
@@ -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 <behavior> 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 ? (
|
||||
<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">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
@@ -274,6 +293,7 @@ export default function AdminModuleGrantsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user