fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500 - Modulansicht unterscheidet Ladefehler von "nicht gefunden" - Formular/Loeschdialog nennen 403 und 400 eigens - eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten - neue Eintraege sind mit "Eigene Module" vorbelegt - Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -100,9 +110,7 @@ const mockActiveModules = [
|
||||
function stubFetch() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
|
||||
),
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
|
||||
});
|
||||
const groups = Array.from(
|
||||
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
screen
|
||||
.getByRole('group', { name: 'Kategorien' })
|
||||
.querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
).map((n) => n.textContent);
|
||||
expect(groups.at(-1)).toBe('custom-modules');
|
||||
expect(groups).toContain('fleet');
|
||||
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-1');
|
||||
});
|
||||
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
|
||||
hrefs().indexOf('/modules/custom/cm-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
|
||||
mockCustomModules = [
|
||||
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
|
||||
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
|
||||
];
|
||||
mockSidebarCollapsed(true);
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-2');
|
||||
});
|
||||
expect(hrefs()).toEqual([
|
||||
'/modules/Domain-Tools/domaincheck',
|
||||
'/modules/custom/cm-1',
|
||||
'/modules/Utilities/converter',
|
||||
'/modules/custom/cm-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
|
||||
.getByText('Domain-Tools')
|
||||
.closest('li')
|
||||
?.querySelectorAll('a span.truncate');
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
|
||||
'Domaincheck',
|
||||
'Aaa Eigenes',
|
||||
]);
|
||||
});
|
||||
|
||||
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
||||
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
|
||||
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
screen
|
||||
.getByText('Domaincheck')
|
||||
.closest('a')
|
||||
?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||
function SelectionPill() {
|
||||
@@ -109,13 +109,17 @@ export function Sidebar() {
|
||||
const linkClass = (href: string) => itemClass(isActive(href));
|
||||
|
||||
const isEntryActive = (entry: SidebarEntry) =>
|
||||
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
isActive(entry.href) ||
|
||||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
|
||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||
// die 20-px-Modulkachel passt weiter hinein.
|
||||
const moduleItemClass = (active: boolean) =>
|
||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||
itemClass(active)
|
||||
.replace('h-9', 'h-8')
|
||||
.replace('gap-3', 'gap-2.5')
|
||||
.replace('text-sm', 'text-[13px]');
|
||||
|
||||
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
||||
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
||||
@@ -186,28 +190,29 @@ export function Sidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
|
||||
// also auch hier immer zuletzt.
|
||||
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
|
||||
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
||||
const orderedCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const list = categories.get(entry.category);
|
||||
if (list) list.push(entry);
|
||||
else categories.set(entry.category, [entry]);
|
||||
}
|
||||
return Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
}, [entries]);
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
|
||||
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
||||
const collapsedEntries = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) =>
|
||||
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
|
||||
),
|
||||
[entries],
|
||||
() => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
|
||||
[orderedCategories],
|
||||
);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
||||
categories.get(entry.category)!.push(entry);
|
||||
}
|
||||
|
||||
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
|
||||
const ordered = Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
const ordered = orderedCategories;
|
||||
if (!searchQuery) return ordered;
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -221,10 +226,22 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [entries, searchQuery, categoryLabel]);
|
||||
}, [orderedCategories, searchQuery, categoryLabel]);
|
||||
|
||||
const navIcon = (children: React.ReactNode) => (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={isCollapsed ? 22 : 20}
|
||||
height={isCollapsed ? 22 : 20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
@@ -234,7 +251,12 @@ export function Sidebar() {
|
||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
||||
<li>
|
||||
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
||||
<Link
|
||||
href="/"
|
||||
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
|
||||
aria-label={t('dashboard')}
|
||||
title={isCollapsed ? t('dashboard') : undefined}
|
||||
>
|
||||
{isActive('/') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -248,7 +270,16 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className={
|
||||
isCollapsed
|
||||
? collapsedItemClass(isActive('/marketplace'))
|
||||
: linkClass('/marketplace')
|
||||
}
|
||||
aria-label={t('marketplace')}
|
||||
title={isCollapsed ? t('marketplace') : undefined}
|
||||
>
|
||||
{isActive('/marketplace') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -268,9 +299,18 @@ export function Sidebar() {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
className={collapsedItemClass(active)}
|
||||
aria-label={entry.name}
|
||||
title={entry.name}
|
||||
>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={24}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -290,9 +330,7 @@ export function Sidebar() {
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{filteredCategories.map(([category, categoryEntries]) => {
|
||||
@@ -307,7 +345,19 @@ export function Sidebar() {
|
||||
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{categoryLabel(category)}</span>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -319,7 +369,11 @@ export function Sidebar() {
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={20}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -353,7 +407,19 @@ export function Sidebar() {
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
title={isCollapsed ? t('expand') : undefined}
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="m16 15-3-3 3-3" />
|
||||
@@ -377,9 +443,7 @@ export function Sidebar() {
|
||||
<aside
|
||||
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
|
||||
style={{
|
||||
width: isCollapsed
|
||||
? 'var(--sidebar-width-collapsed)'
|
||||
: 'var(--sidebar-width)',
|
||||
width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
|
||||
}}
|
||||
>
|
||||
{sidebarContent}
|
||||
@@ -408,7 +472,18 @@ export function Sidebar() {
|
||||
aria-label={tCommon('close')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user