feat(04-02): add module detail page at /marketplace/[slug]
Compact detail view with back link, full description (no line-clamp), status indicator, and activation controls. Reuses ActivationDialog for deactivation. 3 tests pass, 21 total green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,172 @@
|
|||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, params?: Record<string, string>) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
title: 'Marktplatz',
|
||||||
|
activate: 'Modul aktivieren',
|
||||||
|
deactivate: 'Deaktivieren',
|
||||||
|
backToMarketplace: 'Zurueck zum Marktplatz',
|
||||||
|
detailVersion: 'Version {version}',
|
||||||
|
detailStatusActive: 'Aktiviert fuer diesen Mandanten',
|
||||||
|
detailStatusInactive: 'Nicht aktiviert',
|
||||||
|
emptyTitle: 'Noch keine Module verfuegbar',
|
||||||
|
errorLoad: 'Module konnten nicht geladen werden.',
|
||||||
|
toastActivated: 'Modul erfolgreich aktiviert',
|
||||||
|
deactivateDialogTitle: 'Modul deaktivieren',
|
||||||
|
deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?',
|
||||||
|
deactivateDialogConfirm: 'Deaktivieren',
|
||||||
|
deactivateDialogCancel: 'Abbrechen',
|
||||||
|
};
|
||||||
|
let result = translations[key] ?? key;
|
||||||
|
if (params) {
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
result = result.replace(`{${k}}`, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockSlug = 'domaincheck';
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useParams: () => ({ slug: mockSlug }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||||
|
<a href={href}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockAuthStore = vi.fn();
|
||||||
|
vi.mock('@/lib/stores/auth-store', () => ({
|
||||||
|
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/stores/marketplace-store', () => ({
|
||||||
|
useMarketplaceStore: () => ({
|
||||||
|
sidebarRefreshKey: 0,
|
||||||
|
bumpSidebarRefresh: vi.fn(),
|
||||||
|
selectedTenantId: null,
|
||||||
|
setSelectedTenantId: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockModules = [
|
||||||
|
{
|
||||||
|
id: 'mod-1',
|
||||||
|
slug: 'domaincheck',
|
||||||
|
name: 'Domaincheck',
|
||||||
|
version: '1.0.0',
|
||||||
|
category: 'Domain-Tools',
|
||||||
|
description: { de: 'Domain-Verfuegbarkeit pruefen fuer alle gaengigen TLDs', en: 'Check domain availability' },
|
||||||
|
icon: 'Globe',
|
||||||
|
isSystem: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mod-2',
|
||||||
|
slug: 'converter',
|
||||||
|
name: 'Converter',
|
||||||
|
version: '2.0.0',
|
||||||
|
category: 'Utilities',
|
||||||
|
description: { de: 'Dateien konvertieren', en: 'Convert files' },
|
||||||
|
icon: null,
|
||||||
|
isSystem: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockActiveModules = [mockModules[0]];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function importDetailPage() {
|
||||||
|
const mod = await import('./page');
|
||||||
|
return mod.default;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Module Detail Page', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAuthStore.mockImplementation(
|
||||||
|
(selector: (state: { user: { id: string; username: string; displayName: string; role: string; tenantId: string } }) => unknown) =>
|
||||||
|
selector({
|
||||||
|
user: { id: 'u1', username: 'admin', displayName: 'Admin', role: 'ADMIN', tenantId: 't1' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches modules and renders the matching module name and full description', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes('/modules/active')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
||||||
|
}
|
||||||
|
if (url.includes('/modules')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Domain-Verfuegbarkeit pruefen fuer alle gaengigen TLDs')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows active status and deactivate button when module is active', async () => {
|
||||||
|
mockSlug = 'domaincheck';
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes('/modules/active')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) });
|
||||||
|
}
|
||||||
|
if (url.includes('/modules')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Aktiviert fuer diesen Mandanten')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Deaktivieren')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows not-found state when slug does not match any module', async () => {
|
||||||
|
mockSlug = 'nonexistent-module';
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn((url: string) => {
|
||||||
|
if (url.includes('/modules/active')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||||
|
}
|
||||||
|
if (url.includes('/modules')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockModules) });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const Page = await importDetailPage();
|
||||||
|
render(<Page />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('notFound')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
import { useToastStore } from '../components/Toast';
|
||||||
|
import { ActivationDialog } from '../components/ActivationDialog';
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
interface Module {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
category: string;
|
||||||
|
description: Record<string, string>;
|
||||||
|
icon: string | null;
|
||||||
|
isSystem: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModuleDetailPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const t = useTranslations('marketplace');
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
||||||
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
|
const [module, setModule] = useState<Module | null>(null);
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [toggling, setToggling] = useState(false);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
const [pendingDeactivation, setPendingDeactivation] = useState(false);
|
||||||
|
|
||||||
|
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||||
|
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
||||||
|
|
||||||
|
const fetchModule = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (selectedTenantId) {
|
||||||
|
headers['x-tenant-id'] = selectedTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [allRes, activeRes] = await Promise.all([
|
||||||
|
fetch(`${API_URL}/modules`, { credentials: 'include' }),
|
||||||
|
fetch(`${API_URL}/modules/active`, { credentials: 'include', headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (allRes.ok) {
|
||||||
|
const allModules: Module[] = await allRes.json();
|
||||||
|
const found = allModules.find((m) => m.slug === slug);
|
||||||
|
if (found) {
|
||||||
|
setModule(found);
|
||||||
|
if (activeRes.ok) {
|
||||||
|
const activeModules: Module[] = await activeRes.json();
|
||||||
|
setIsActive(activeModules.some((m) => m.id === found.id));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNotFound(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [slug, selectedTenantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAdmin) {
|
||||||
|
fetchModule();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAdmin, fetchModule]);
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (isActive) {
|
||||||
|
setPendingDeactivation(true);
|
||||||
|
} else {
|
||||||
|
performToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const performToggle = async () => {
|
||||||
|
if (!module) return;
|
||||||
|
setToggling(true);
|
||||||
|
try {
|
||||||
|
const action = isActive ? 'deactivate' : 'activate';
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (selectedTenantId) {
|
||||||
|
headers['x-tenant-id'] = selectedTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_URL}/modules/${module.id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setIsActive(!isActive);
|
||||||
|
bumpSidebarRefresh();
|
||||||
|
addToast('success', isActive ? t('toastDeactivated') : t('toastActivated'));
|
||||||
|
} else {
|
||||||
|
addToast('error', isActive ? t('toastDeactivateError') : t('toastActivateError'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setToggling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeactivation = () => {
|
||||||
|
setPendingDeactivation(false);
|
||||||
|
performToggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[40vh]">
|
||||||
|
<span className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<p className="text-lg text-muted-foreground">{t('accessDenied')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound || !module) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
|
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
{t('backToMarketplace')}
|
||||||
|
</Link>
|
||||||
|
<p className="text-lg text-muted-foreground">{t('notFound')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const localizedDescription = module.description[locale] || module.description.en || module.description.de || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
|
<Link href="/marketplace" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
{t('backToMarketplace')}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="rounded-lg bg-muted p-4 shrink-0">
|
||||||
|
{module.icon === 'Globe' ? (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-primary">
|
||||||
|
<path d="M19.439 7.85c-.049.322.059.648.289.878l1.568 1.568c.47.47.706 1.087.706 1.704s-.235 1.233-.706 1.704l-1.611 1.611a.98.98 0 0 1-.837.276c-.47-.07-.802-.48-.968-.925a2.501 2.501 0 1 0-3.214 3.214c.446.166.855.497.925.968a.979.979 0 0 1-.276.837l-1.61 1.61a2.404 2.404 0 0 1-1.705.707 2.402 2.402 0 0 1-1.704-.706l-1.568-1.568a1.026 1.026 0 0 0-.877-.29c-.493.074-.84.504-1.02.968a2.5 2.5 0 1 1-3.237-3.237c.464-.18.894-.527.967-1.02a1.026 1.026 0 0 0-.289-.877l-1.568-1.568A2.402 2.402 0 0 1 1.998 12c0-.617.236-1.234.706-1.704L4.315 8.685a.98.98 0 0 1 .837-.276c.47.07.802.48.968.925a2.501 2.501 0 1 0 3.214-3.214c-.446-.166-.855-.497-.925-.968a.979.979 0 0 1 .276-.837l1.61-1.61a2.404 2.404 0 0 1 1.705-.707c.617 0 1.234.236 1.704.706l1.568 1.568c.23.23.556.338.877.29.493-.074.84-.504 1.02-.968a2.5 2.5 0 1 1 3.237 3.237c-.464.18-.894.527-.967 1.02Z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">{module.name}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{module.category}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('detailVersion', { version: module.version })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground">{localizedDescription}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||||
|
<span className={`text-sm font-medium ${isActive ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'}`}>
|
||||||
|
{isActive ? t('detailStatusActive') : t('detailStatusInactive')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={toggling}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors disabled:cursor-wait disabled:opacity-50 ${
|
||||||
|
isActive
|
||||||
|
? 'border border-border text-foreground hover:bg-muted'
|
||||||
|
: 'bg-primary text-primary-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toggling ? (
|
||||||
|
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
) : isActive ? (
|
||||||
|
t('deactivate')
|
||||||
|
) : (
|
||||||
|
t('activate')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivationDialog
|
||||||
|
open={pendingDeactivation}
|
||||||
|
moduleName={module.name}
|
||||||
|
onConfirm={confirmDeactivation}
|
||||||
|
onCancel={() => setPendingDeactivation(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user