feat(quick-261008-w5w): Modul-Changelog Durchstich mit Nextcloud-Status

- Typen, Register, Route GET /modules/changelog/:slug, Waechter-Test
- Seed liest die Version per latestVersion aus dem Changelog
- Marktplatz-Detailseite zeigt den Abschnitt Aenderungen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 23:25:02 +02:00
parent 1d339903e0
commit 79210d7966
12 changed files with 674 additions and 2 deletions
@@ -1,4 +1,4 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
@@ -18,6 +18,12 @@ vi.mock('next-intl', () => ({
deactivateDialogBody: 'Moechten Sie {moduleName} deaktivieren?',
deactivateDialogConfirm: 'Deaktivieren',
deactivateDialogCancel: 'Abbrechen',
changelogTitle: 'Änderungen',
changelogCurrent: 'Aktuell',
changelogRelease: 'Version {version} vom {date}',
changelogKindNew: 'Neu',
changelogKindChanged: 'Geändert',
changelogKindFixed: 'Behoben',
};
let result = translations[key] ?? key;
if (params) {
@@ -88,6 +94,43 @@ const mockCatalogActive = [
mockCatalog[1],
];
const mockReleases = [
{
version: '1.1.0',
date: '2026-10-08',
changes: [
{ kind: 'fixed', de: 'Behobener Fehler im Formular', en: 'Fixed form bug' },
{ kind: 'new', de: 'Neues Suchfeld', en: 'New search field' },
{ kind: 'changed', de: 'Kompaktere Kacheln', en: 'More compact tiles' },
],
},
{
version: '1.0.0',
date: '2026-10-06',
changes: [{ kind: 'new', de: 'Erste Fassung der Kacheln', en: 'First version of the tiles' }],
},
];
/** URL-abhaengige fetch-Attrappe: Katalog und Changelog getrennt. */
function stubFetch(catalog: unknown, changelog: () => Promise<unknown> | unknown = () => []) {
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
if (url.includes('/modules/catalog')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(catalog) });
}
if (url.includes('/modules/changelog/')) {
return Promise.resolve(changelog()).then((res) =>
res && typeof res === 'object' && 'ok' in (res as object)
? res
: { ok: true, json: () => Promise.resolve(res) },
);
}
return Promise.resolve({ ok: false, text: () => Promise.resolve('') });
}),
);
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
@@ -169,4 +212,81 @@ describe('Module Detail Page', () => {
expect(screen.getByText('notFound')).toBeInTheDocument();
});
});
it('zeigt den Abschnitt Aenderungen: neueste Version offen mit Aktuell, aeltere zugeklappt', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => mockReleases);
const Page = await importDetailPage();
const { container } = render(<Page />);
expect(await screen.findByRole('heading', { name: 'Änderungen' })).toBeInTheDocument();
expect(screen.getByText('Aktuell')).toBeInTheDocument();
expect(screen.getByText('Neues Suchfeld')).toBeInTheDocument();
expect(screen.getByText('Kompaktere Kacheln')).toBeInTheDocument();
expect(screen.getByText('Behobener Fehler im Formular')).toBeInTheDocument();
// Gruppen in fester Reihenfolge Neu, Geaendert, Behoben
const labels = Array.from(container.querySelectorAll('section > div p.uppercase')).map(
(n) => n.textContent,
);
expect(labels).toEqual(['Neu', 'Geändert', 'Behoben']);
const details = container.querySelectorAll('details');
expect(details).toHaveLength(1);
expect(details[0].open).toBe(false);
const summary = within(details[0] as HTMLElement).getByText(/Version 1\.0\.0 vom/);
expect(summary.tagName).toBe('SUMMARY');
expect(summary.textContent).toContain('2026');
expect(within(details[0] as HTMLElement).getByText('Erste Fassung der Kacheln')).toBeInTheDocument();
});
it('zeigt bei leerer Liste keinen Abschnitt Aenderungen', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => []);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
it('zeigt das Modul normal, wenn der Changelog-Abruf mit 500 antwortet', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => ({ ok: false, status: 500, json: () => Promise.resolve({}) }));
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
it('zeigt das Modul normal bei Netzwerkfehler des Changelog-Abrufs', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => Promise.reject(new Error('offline')));
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
it('verwirft eine Antwort, die kein Array von Versionen ist', async () => {
mockSlug = 'domaincheck';
stubFetch(mockCatalogActive, () => [{ foo: 'bar' }]);
const Page = await importDetailPage();
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
cleanup();
stubFetch(mockCatalogActive, () => ({ version: '1.0.0', date: 'x', changes: [] }));
render(<Page />);
await waitFor(() => expect(screen.getByText('Domaincheck')).toBeInTheDocument());
expect(screen.queryByRole('heading', { name: 'Änderungen' })).not.toBeInTheDocument();
});
});
@@ -10,6 +10,7 @@ import { useAuthStore } from '@/lib/stores/auth-store';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useToastStore } from '../components/Toast';
import { ActivationDialog } from '../components/ActivationDialog';
import { ModuleChangelog, type ChangelogRelease } from '../components/ModuleChangelog';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -44,6 +45,7 @@ export default function ModuleDetailPage() {
const [toggling, setToggling] = useState(false);
const [notFound, setNotFound] = useState(false);
const [pendingDeactivation, setPendingDeactivation] = useState(false);
const [releases, setReleases] = useState<ChangelogRelease[]>([]);
// isAdmin gates module MANAGEMENT (activate/deactivate) only — the
// catalog itself stays open to every authenticated user (D-08).
@@ -79,6 +81,46 @@ export default function ModuleDetailPage() {
fetchModule();
}, [fetchModule]);
// quick-261008-w5w: Modul-Changelog; jeder Fehler oder jede unerwartete Antwort
// ergibt eine leere Liste (kein Abschnitt, kein Toast).
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const headers: Record<string, string> = {};
if (selectedTenantId) {
headers['x-tenant-id'] = selectedTenantId;
}
const res = await fetch(`${API_URL}/modules/changelog/${encodeURIComponent(slug)}`, {
credentials: 'include',
headers,
});
if (!res.ok) {
if (!cancelled) setReleases([]);
return;
}
const data: unknown = await res.json();
const valid =
Array.isArray(data) &&
data.every(
(r) =>
r !== null &&
typeof r === 'object' &&
typeof (r as ChangelogRelease).version === 'string' &&
typeof (r as ChangelogRelease).date === 'string' &&
Array.isArray((r as ChangelogRelease).changes),
);
if (!cancelled) setReleases(valid ? (data as ChangelogRelease[]) : []);
} catch {
if (!cancelled) setReleases([]);
}
};
load();
return () => {
cancelled = true;
};
}, [slug, selectedTenantId]);
const handleToggle = () => {
if (module?.isActiveForTenant) {
setPendingDeactivation(true);
@@ -194,6 +236,8 @@ export default function ModuleDetailPage() {
)}
</div>
<ModuleChangelog releases={releases} locale={locale} />
<ActivationDialog
open={pendingDeactivation}
moduleName={module.name}
@@ -0,0 +1,108 @@
'use client';
import { useTranslations } from 'next-intl';
/**
* Abschnitt "Änderungen" der Marktplatz-Detailseite (quick-261008-w5w).
* Eigene schmale Schnittstellen, kein Import aus der API. Texte werden nur als
* React-Textknoten ausgegeben (kein HTML, kein Markdown; T-261008-w5w-03).
*/
export type ChangeKind = 'new' | 'changed' | 'fixed';
export interface ChangelogItem {
kind: ChangeKind;
de: string;
en: string;
}
export interface ChangelogRelease {
version: string;
date: string;
changes: ChangelogItem[];
}
const KIND_ORDER: ChangeKind[] = ['new', 'changed', 'fixed'];
interface Props {
releases: ChangelogRelease[];
locale: string;
}
function formatDate(date: string, locale: string): string {
const parsed = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return date;
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(parsed);
}
function ReleaseChanges({ release, locale }: { release: ChangelogRelease; locale: string }) {
const t = useTranslations('marketplace');
const kindLabel: Record<ChangeKind, string> = {
new: t('changelogKindNew'),
changed: t('changelogKindChanged'),
fixed: t('changelogKindFixed'),
};
const text = (item: ChangelogItem) =>
(locale === 'en' ? item.en : item.de) || item.de;
return (
<div className="space-y-3">
{KIND_ORDER.map((kind) => {
const items = release.changes.filter((c) => c.kind === kind);
if (items.length === 0) return null;
return (
<div key={kind}>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{kindLabel[kind]}
</p>
<ul className="mt-1 list-disc space-y-1 pl-5 text-sm text-foreground">
{items.map((item, index) => (
<li key={`${kind}-${index}`}>{text(item)}</li>
))}
</ul>
</div>
);
})}
</div>
);
}
export function ModuleChangelog({ releases, locale }: Props) {
const t = useTranslations('marketplace');
if (releases.length === 0) return null;
const [latest, ...older] = releases;
const releaseLabel = (release: ChangelogRelease) =>
t('changelogRelease', { version: release.version, date: formatDate(release.date, locale) });
return (
<section className="space-y-3">
<h2 className="text-lg font-semibold text-foreground">{t('changelogTitle')}</h2>
<div className="rounded-md border border-border bg-card p-4 space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-foreground">{releaseLabel(latest)}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{t('changelogCurrent')}
</span>
</div>
<ReleaseChanges release={latest} locale={locale} />
</div>
{older.map((release) => (
<details key={release.version} className="rounded-md border border-border bg-card p-4">
<summary className="cursor-pointer text-sm font-medium text-foreground">
{releaseLabel(release)}
</summary>
<div className="mt-3">
<ReleaseChanges release={release} locale={locale} />
</div>
</details>
))}
</section>
);
}
+6
View File
@@ -1234,6 +1234,12 @@
"tenantContextLabel": "Organisation",
"backToMarketplace": "Zurück zum Marktplatz",
"detailVersion": "Version {version}",
"changelogTitle": "Änderungen",
"changelogCurrent": "Aktuell",
"changelogRelease": "Version {version} vom {date}",
"changelogKindNew": "Neu",
"changelogKindChanged": "Geändert",
"changelogKindFixed": "Behoben",
"detailStatusActive": "Aktiviert",
"detailStatusInactive": "Nicht aktiviert",
"accessDenied": "Zugriff verweigert",
+6
View File
@@ -1234,6 +1234,12 @@
"tenantContextLabel": "Organization",
"backToMarketplace": "Back to Marketplace",
"detailVersion": "Version {version}",
"changelogTitle": "Changes",
"changelogCurrent": "Current",
"changelogRelease": "Version {version}, {date}",
"changelogKindNew": "New",
"changelogKindChanged": "Changed",
"changelogKindFixed": "Fixed",
"detailStatusActive": "Activated",
"detailStatusInactive": "Not activated",
"accessDenied": "Access denied",