fix(quick-260921-jt4): MarketplaceCard-Klick wird echte deckende Schaltflaeche
- Karten-onClick entfernt, deckende <button> als Geschwister traegt handleCardClick nur wenn isActive (D-01, verschachtelte Buttons vermieden wie bei DropZone.tsx aus 260921-bi2) - Aktivieren/Deaktivieren-Schaltflaeche bleibt eigener Tab-Stopp, Fussbereich bekommt relative fuer korrekten Stapelkontext - Neuer Katalogschluessel marketplace.openDetail in de.json/en.json - Tests: echte Tastaturbetaetigung (Enter auf fokussierter Schaltflaeche) statt Klick-Behauptung; kein Overlay im nicht-aktivierten Zustand Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
This commit is contained in:
@@ -4,15 +4,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
// Mock next-intl useTranslations
|
// Mock next-intl useTranslations
|
||||||
vi.mock('next-intl', () => ({
|
vi.mock('next-intl', () => ({
|
||||||
useTranslations: () => (key: string) => {
|
useTranslations: () => (key: string, values?: Record<string, string | number>) => {
|
||||||
const translations: Record<string, string> = {
|
const translations: Record<string, string> = {
|
||||||
activate: 'Modul aktivieren',
|
activate: 'Modul aktivieren',
|
||||||
deactivate: 'Deaktivieren',
|
deactivate: 'Deaktivieren',
|
||||||
statusActive: 'Aktiviert',
|
statusActive: 'Aktiviert',
|
||||||
statusAvailable: 'Verfuegbar',
|
statusAvailable: 'Verfuegbar',
|
||||||
statusNoAccess: 'Kein Zugriff',
|
statusNoAccess: 'Kein Zugriff',
|
||||||
|
openDetail: 'Details zu {name} öffnen',
|
||||||
};
|
};
|
||||||
return translations[key] ?? key;
|
const template = translations[key] ?? key;
|
||||||
|
if (!values) return template;
|
||||||
|
return Object.entries(values).reduce(
|
||||||
|
(acc, [placeholder, value]) => acc.replace(`{${placeholder}}`, String(value)),
|
||||||
|
template,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -81,7 +87,15 @@ describe('MarketplaceCard', () => {
|
|||||||
expect(onToggle).toHaveBeenCalledWith('mod-1', false);
|
expect(onToggle).toHaveBeenCalledWith('mod-1', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows the "Kein Zugriff" badge for an active module without access and clicking the card triggers the toast instead of navigating (D-08)', async () => {
|
it('does not render a card overlay button for a not-yet-activated module (D-01)', () => {
|
||||||
|
render(<MarketplaceCard {...baseProps} isActive={false} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'Details zu Domaincheck öffnen' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the "Kein Zugriff" badge for an active module without access and mouse-clicking the overlay button triggers the toast instead of navigating (D-08)', async () => {
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
const onLockedClick = vi.fn();
|
const onLockedClick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -98,13 +112,13 @@ describe('MarketplaceCard', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Kein Zugriff')).toBeInTheDocument();
|
expect(screen.getByText('Kein Zugriff')).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByText('Domaincheck'));
|
await user.click(screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' }));
|
||||||
|
|
||||||
expect(onLockedClick).toHaveBeenCalledOnce();
|
expect(onLockedClick).toHaveBeenCalledOnce();
|
||||||
expect(onOpenDetail).not.toHaveBeenCalled();
|
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not show the "Kein Zugriff" badge for an active module with access and clicking the card navigates', async () => {
|
it('does not show the "Kein Zugriff" badge for an active module with access and mouse-clicking the overlay button navigates', async () => {
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
const onLockedClick = vi.fn();
|
const onLockedClick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -121,14 +135,80 @@ describe('MarketplaceCard', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument();
|
expect(screen.queryByText('Kein Zugriff')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByText('Domaincheck'));
|
await user.click(screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' }));
|
||||||
|
|
||||||
expect(onOpenDetail).toHaveBeenCalledOnce();
|
expect(onOpenDetail).toHaveBeenCalledOnce();
|
||||||
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
|
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
|
||||||
expect(onLockedClick).not.toHaveBeenCalled();
|
expect(onLockedClick).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the not-activated state unchanged: no access badge, and clicking the card does not fire navigate or locked callbacks', async () => {
|
it('opens the module detail via a real keyboard activation of the focused overlay button (D-01)', async () => {
|
||||||
|
const onOpenDetail = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarketplaceCard
|
||||||
|
{...baseProps}
|
||||||
|
isActive={true}
|
||||||
|
hasAccess={true}
|
||||||
|
onOpenDetail={onOpenDetail}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlay = screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' });
|
||||||
|
overlay.focus();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onOpenDetail).toHaveBeenCalledOnce();
|
||||||
|
expect(onOpenDetail).toHaveBeenCalledWith('domaincheck');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers the locked hint via a real keyboard activation of the focused overlay button (D-01, D-08)', async () => {
|
||||||
|
const onLockedClick = vi.fn();
|
||||||
|
const onOpenDetail = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarketplaceCard
|
||||||
|
{...baseProps}
|
||||||
|
isActive={true}
|
||||||
|
hasAccess={false}
|
||||||
|
onLockedClick={onLockedClick}
|
||||||
|
onOpenDetail={onOpenDetail}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overlay = screen.getByRole('button', { name: 'Details zu Domaincheck öffnen' });
|
||||||
|
overlay.focus();
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
expect(onLockedClick).toHaveBeenCalledOnce();
|
||||||
|
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the activate/deactivate footer button as its own reachable tab stop that does not additionally open the detail overlay', async () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
const onOpenDetail = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MarketplaceCard
|
||||||
|
{...baseProps}
|
||||||
|
isActive={true}
|
||||||
|
hasAccess={true}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onOpenDetail={onOpenDetail}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Deaktivieren' }));
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledOnce();
|
||||||
|
expect(onToggle).toHaveBeenCalledWith('mod-1', true);
|
||||||
|
expect(onOpenDetail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the not-activated state unchanged: no access badge, no overlay button, and clicking the visible text does not fire navigate or locked callbacks', async () => {
|
||||||
const onOpenDetail = vi.fn();
|
const onOpenDetail = vi.fn();
|
||||||
const onLockedClick = vi.fn();
|
const onLockedClick = vi.fn();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -114,16 +114,22 @@ export function MarketplaceCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
|
className={`relative rounded-lg border border-border bg-card p-5 shadow-sm transition-all ${
|
||||||
locked
|
locked ? 'opacity-60' : 'hover:shadow-md hover:border-primary/30'
|
||||||
? 'opacity-60 cursor-not-allowed'
|
|
||||||
: isActive
|
|
||||||
? 'hover:shadow-md hover:border-primary/30 cursor-pointer'
|
|
||||||
: 'hover:shadow-md hover:border-primary/30'
|
|
||||||
}`}
|
}`}
|
||||||
style={{ minHeight: '180px' }}
|
style={{ minHeight: '180px' }}
|
||||||
onClick={isActive ? handleCardClick : undefined}
|
|
||||||
>
|
>
|
||||||
|
{/* Deckende Schaltflaeche: traegt den Kartenklick, damit die verschachtelte
|
||||||
|
Aktivieren-Schaltflaeche im Fuss kein <button> in einem <button> wird. */}
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCardClick}
|
||||||
|
aria-label={t('openDetail', { name })}
|
||||||
|
className={`absolute inset-0 rounded-lg ${locked ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header: icon + name + badges */}
|
{/* Header: icon + name + badges */}
|
||||||
<div className="flex items-start gap-3 mb-3">
|
<div className="flex items-start gap-3 mb-3">
|
||||||
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
<div className="rounded-md bg-muted p-2.5 shrink-0">
|
||||||
@@ -167,7 +173,7 @@ export function MarketplaceCard({
|
|||||||
|
|
||||||
{/* Footer: action button — only rendered for users who may activate/deactivate */}
|
{/* Footer: action button — only rendered for users who may activate/deactivate */}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<div className="flex items-center justify-end">
|
<div className="relative flex items-center justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -747,7 +747,8 @@
|
|||||||
"accessDenied": "Zugriff verweigert",
|
"accessDenied": "Zugriff verweigert",
|
||||||
"statusNoAccess": "Kein Zugriff",
|
"statusNoAccess": "Kein Zugriff",
|
||||||
"toastNoAccess": "Kein Zugriff auf dieses Modul — wenden Sie sich an Ihren Administrator.",
|
"toastNoAccess": "Kein Zugriff auf dieses Modul — wenden Sie sich an Ihren Administrator.",
|
||||||
"notFound": "Dieses Modul wurde nicht gefunden."
|
"notFound": "Dieses Modul wurde nicht gefunden.",
|
||||||
|
"openDetail": "Details zu {name} öffnen"
|
||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Zertifikat-Manager",
|
"title": "Zertifikat-Manager",
|
||||||
|
|||||||
@@ -747,7 +747,8 @@
|
|||||||
"accessDenied": "Access denied",
|
"accessDenied": "Access denied",
|
||||||
"statusNoAccess": "No Access",
|
"statusNoAccess": "No Access",
|
||||||
"toastNoAccess": "No access to this module — please contact your administrator.",
|
"toastNoAccess": "No access to this module — please contact your administrator.",
|
||||||
"notFound": "This module was not found."
|
"notFound": "This module was not found.",
|
||||||
|
"openDetail": "Open details for {name}"
|
||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Certificate Manager",
|
"title": "Certificate Manager",
|
||||||
|
|||||||
Reference in New Issue
Block a user