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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user