feat: Kategorie "Eigene Module" fuer selbst angelegte Eintraege
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m27s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m33s

CUSTOM_MODULE_CATEGORIES = MODULE_CATEGORIES + custom-modules; API prueft
dagegen, das Formular bietet sie an. Die Seitenleiste zeigt die Gruppe wie
jede Kategorie nur mit Eintrag und stellt sie immer ans Ende. Nutzerwunsch
29.09.; ohne Migration (category ist Freitext mit IsIn-Pruefung).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 11:24:56 +02:00
parent 00c2cfe2c9
commit 4ff9a239fd
10 changed files with 63 additions and 13 deletions
+4
View File
@@ -4,6 +4,10 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
### Neu
- Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
### Geändert ### Geändert
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander. - Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
@@ -1,5 +1,5 @@
import { OmitType, PartialType } from '@nestjs/mapped-types'; import { OmitType, PartialType } from '@nestjs/mapped-types';
import { MODULE_CATEGORIES } from '@tessera/shared'; import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
@@ -60,8 +60,8 @@ export class CreateCustomModuleDto {
@Validate(NurHttpsOhneZugangsdatenConstraint) @Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string; url!: string;
@IsIn([...MODULE_CATEGORIES]) @IsIn([...CUSTOM_MODULE_CATEGORIES])
category!: (typeof MODULE_CATEGORIES)[number]; category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
/** /**
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer * quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { MODULE_CATEGORIES } from '@tessera/shared'; import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { import {
type CustomModule, type CustomModule,
checkCustomModuleUrl, checkCustomModuleUrl,
@@ -142,7 +142,7 @@ export function CustomModuleFormModal({
onChange={(e) => setCategory(e.target.value)} onChange={(e) => setCategory(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
> >
{MODULE_CATEGORIES.map((c) => ( {CUSTOM_MODULE_CATEGORIES.map((c) => (
<option key={c} value={c}> <option key={c} value={c}>
{categoryLabel(c)} {categoryLabel(c)}
</option> </option>
@@ -299,6 +299,32 @@ describe('Sidebar', () => {
expect(screen.getByText('Domain-Tools')).toBeInTheDocument(); expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
}); });
it('Kategorie „Eigene Module“ steht zuletzt und nur, wenn ein Eintrag darin liegt', async () => {
mockCustomModules = [
customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }),
customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }),
];
const Sidebar = await importSidebar();
const { unmount } = render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
});
const groups = Array.from(
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');
unmount();
mockCustomModules = [customModule({ category: 'fleet' })];
render(<Sidebar />);
await waitFor(() => {
expect(screen.getByText('fleet')).toBeInTheDocument();
});
expect(screen.queryByText('custom-modules')).toBeNull();
});
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => { it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })]; mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
const Sidebar = await importSidebar(); const Sidebar = await importSidebar();
+7 -2
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { TesseraLogo } from '@/components/brand/tessera-logo'; import { TesseraLogo } from '@/components/brand/tessera-logo';
import { useSidebarStore } from '@/lib/stores/sidebar-store'; import { useSidebarStore } from '@/lib/stores/sidebar-store';
@@ -192,11 +193,15 @@ export function Sidebar() {
categories.get(entry.category)!.push(entry); categories.get(entry.category)!.push(entry);
} }
if (!searchQuery) return Array.from(categories.entries()); // „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),
);
if (!searchQuery) return ordered;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
const result: [string, SidebarEntry[]][] = []; const result: [string, SidebarEntry[]][] = [];
for (const [cat, mods] of categories.entries()) { for (const [cat, mods] of ordered) {
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) { if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
result.push([cat, mods]); result.push([cat, mods]);
} else { } else {
+2 -1
View File
@@ -1396,6 +1396,7 @@
"security-tools": "Sicherheit", "security-tools": "Sicherheit",
"fleet": "Fuhrpark", "fleet": "Fuhrpark",
"infrastructure": "Infrastruktur", "infrastructure": "Infrastruktur",
"procurement": "Beschaffung" "procurement": "Beschaffung",
"custom-modules": "Eigene Module"
} }
} }
+2 -1
View File
@@ -1396,6 +1396,7 @@
"security-tools": "Security", "security-tools": "Security",
"fleet": "Fleet", "fleet": "Fleet",
"infrastructure": "Infrastructure", "infrastructure": "Infrastructure",
"procurement": "Procurement" "procurement": "Procurement",
"custom-modules": "Custom modules"
} }
} }
@@ -1,4 +1,4 @@
import { MODULE_CATEGORIES } from '@tessera/shared'; import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import de from './de.json'; import de from './de.json';
import en from './en.json'; import en from './en.json';
@@ -9,13 +9,13 @@ import en from './en.json';
* API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen — * API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen —
* sonst zeigte die Seitenleiste die rohe Kennung. * sonst zeigte die Seitenleiste die rohe Kennung.
*/ */
describe('MODULE_CATEGORIES', () => { describe('CUSTOM_MODULE_CATEGORIES (inkl. MODULE_CATEGORIES)', () => {
it.each([ it.each([
['de', de], ['de', de],
['en', en], ['en', en],
] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => { ] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => {
const labels = messages.moduleCategories as Record<string, string>; const labels = messages.moduleCategories as Record<string, string>;
for (const category of MODULE_CATEGORIES) { for (const category of CUSTOM_MODULE_CATEGORIES) {
expect(labels[category], `moduleCategories.${category}`).toBeTruthy(); expect(labels[category], `moduleCategories.${category}`).toBeTruthy();
} }
}); });
+1 -1
View File
@@ -176,7 +176,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück. - **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera. - **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern. **Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“ – oder „Eigene Module“, dann stehen Ihre Einträge gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste. Diese Gruppe erscheint nur, solange ein Eintrag darin liegt. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters. **Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.
+13
View File
@@ -274,3 +274,16 @@ export const MODULE_CATEGORIES = [
] as const; ] as const;
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number]; export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];
/**
* Zusaetzliche Kategorie nur fuer eigene Module (Nutzerwunsch 29.09.2026):
* „Eigene Module“ sammelt selbst angelegte Eintraege in einer eigenen Gruppe
* der Seitenleiste. Wie jede Kategorie erscheint sie nur, wenn ein Eintrag
* darin liegt, und steht immer zuletzt.
*/
export const CUSTOM_MODULE_CATEGORY = "custom-modules" as const;
/** Auswahl im Formular „Eigene Module“ und Pruefung in der API. */
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];