feat(nextcloud-status): Suche nach Kundenname, kompaktere Kacheln, Adresse in voller Laenge
Tessera CI/CD / Lint & Type Check (push) Successful in 55s
Tessera CI/CD / Tests (push) Successful in 2m24s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m41s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:29:15 +02:00
parent 57ab064abe
commit e18b3c8e24
6 changed files with 92 additions and 35 deletions
+1
View File
@@ -15,6 +15,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Geändert
- DKV-Rechnung: Die Modulbeschreibung im Marktplatz weist jetzt darauf hin, dass das Modul die Freigabestufe „Verwalten“ benötigt. Mit „Benutzen“ erscheint es zwar in der Seitenleiste, öffnet aber nur die Seite „Kein Zugriff“. Modulversion 1.1.0.
- Nextcloud-Status: Über der Kachelliste gibt es jetzt ein Suchfeld. Es zeigt nur die Clouds, deren Kundenname den eingegebenen Text enthält; Groß- und Kleinschreibung spielen keine Rolle, die gewählte Sortierung bleibt erhalten. Die Kacheln sind kompakter, und die Adresse der Cloud steht in voller Länge in einer eigenen Zeile, statt abgeschnitten zu werden.
- Nextcloud-Status: Als Logo können Sie jetzt auch eine Bildadresse eingeben, die mit http:// beginnt. Tessera holt das Bild beim Speichern einmalig ab und behält es wie ein hochgeladenes Bild (höchstens 1 MB; PNG, JPEG, GIF oder WebP). Adressen, die nur intern erreichbar sind, holt Tessera nicht ab – solche Bilder laden Sie über „Bild hochladen“ hoch. Adressen mit https:// lädt weiterhin direkt Ihr Browser. Das Feld heißt jetzt „Bildadresse“.
### Behoben
@@ -74,11 +74,11 @@ export function CloudTile({
className="relative flex h-full flex-col overflow-hidden rounded-lg bg-card shadow-sm dark:border dark:border-border"
>
<span aria-hidden="true" className={`absolute inset-y-0 left-0 w-1 ${style.fill}`} />
<div className="flex flex-1 flex-col gap-3 py-4 pl-5 pr-4">
<div className="flex items-start gap-3">
<div className="flex flex-1 flex-col gap-2 py-3 pl-4 pr-3">
<div className="flex items-center gap-2.5">
<div
className={`flex h-10 shrink-0 items-center justify-center overflow-hidden text-sm font-semibold text-muted-foreground ${
src ? 'max-w-24' : 'w-10 bg-well'
className={`flex h-8 shrink-0 items-center justify-center overflow-hidden text-xs font-semibold text-muted-foreground ${
src ? 'max-w-20' : 'w-8 rounded-md bg-well'
}`}
>
{src ? (
@@ -90,7 +90,7 @@ export function CloudTile({
alt={instance.customerName}
referrerPolicy="no-referrer"
decoding="async"
className="h-10 w-auto max-w-24 object-contain"
className="h-8 w-auto max-w-20 object-contain"
onError={() => setLogoFailed(true)}
/>
) : (
@@ -101,15 +101,6 @@ export function CloudTile({
<div className="truncate font-semibold leading-tight" title={instance.customerName}>
{instance.customerName}
</div>
<a
href={instance.baseUrl}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-xs text-muted-foreground underline-offset-2 hover:underline"
title={t('card.openCloud')}
>
{instance.baseUrl}
</a>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
@@ -193,10 +184,21 @@ export function CloudTile({
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{/* Adresse in voller Laenge auf eigener Zeile — bricht notfalls um statt abgeschnitten zu werden. */}
<a
href={instance.baseUrl}
target="_blank"
rel="noopener noreferrer"
className="block break-all text-xs text-muted-foreground underline-offset-2 hover:underline"
title={t('card.openCloud')}
>
{instance.baseUrl}
</a>
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
<span
data-testid="rating-pill"
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${style.pill}`}
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${style.pill}`}
>
<span aria-hidden="true" className={`h-1.5 w-1.5 rounded-full ${style.fill}`} />
{reason}
@@ -231,7 +233,7 @@ export function CloudTile({
<div
data-testid="last-check"
className="mt-auto border-t border-border/60 pt-3 text-xs text-muted-foreground"
className="mt-auto text-xs text-muted-foreground"
title={
instance.status.checkedAt
? new Date(instance.status.checkedAt).toLocaleString(locale)
@@ -234,6 +234,26 @@ describe('NextcloudStatusPage', () => {
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
});
it('Suche filtert nach Kundenname ohne Groß-/Kleinschreibung', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
const search = screen.getByLabelText('Clouds durchsuchen');
fireEvent.change(search, { target: { value: 'gr' } });
expect(tileNames()).toEqual(['Kunde Grau', 'Kunde Grün']);
fireEvent.change(search, { target: { value: 'ROT' } });
expect(tileNames()).toEqual(['Kunde Rot']);
fireEvent.change(search, { target: { value: 'xyz' } });
expect(screen.queryAllByTestId('cloud-tile')).toHaveLength(0);
expect(screen.getByTestId('search-empty')).toHaveTextContent('Keine Cloud passt zu „xyz“.');
fireEvent.change(search, { target: { value: '' } });
expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4);
});
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
@@ -1,6 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PageHeader } from '@/components/layout/page-header';
import {
@@ -77,6 +77,7 @@ function RefreshIcon({ spinning }: { spinning: boolean }) {
*/
export default function NextcloudStatusPage() {
const t = useTranslations('nextcloudStatus');
const locale = useLocale();
const userId = useAuthStore((s) => s.user?.id ?? null);
const canManage = useCanManageModule('nextcloud-status') === true;
@@ -84,6 +85,7 @@ export default function NextcloudStatusPage() {
const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now());
const [sortKey, setSortKey] = useState<SortKey>('name');
const [query, setQuery] = useState('');
const [checkingAll, setCheckingAll] = useState(false);
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
const [togglingIds, setTogglingIds] = useState<ReadonlySet<string>>(new Set());
@@ -204,6 +206,11 @@ export default function NextcloudStatusPage() {
// Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
// neueste Version kommen so wieder aus einer Quelle.
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
// Suche nach Kundenname, ohne Gross-/Kleinschreibung; die Sortierung bleibt erhalten.
const needle = query.trim().toLocaleLowerCase(locale);
const visible = needle
? sorted.filter((i) => i.customerName.toLocaleLowerCase(locale).includes(needle))
: sorted;
const newest = data?.reference.newestVersion ?? null;
return (
@@ -267,20 +274,31 @@ export default function NextcloudStatusPage() {
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
</p>
{data.instances.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t('sort.label')}
<select
value={sortKey}
onChange={(e) => handleSort(e.target.value as SortKey)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
>
{SORT_KEYS.map((key) => (
<option key={key} value={key}>
{t(`sort.${key}`)}
</option>
))}
</select>
</label>
<div className="flex flex-wrap items-center gap-3">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('search.placeholder')}
aria-label={t('search.label')}
data-testid="cloud-search"
className="h-9 w-56 rounded-md border border-input bg-background px-3 text-sm text-foreground"
/>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t('sort.label')}
<select
value={sortKey}
onChange={(e) => handleSort(e.target.value as SortKey)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
>
{SORT_KEYS.map((key) => (
<option key={key} value={key}>
{t(`sort.${key}`)}
</option>
))}
</select>
</label>
</div>
)}
</div>
)}
@@ -292,9 +310,15 @@ export default function NextcloudStatusPage() {
</div>
)}
{!error && data !== null && sorted.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sorted.map((instance) => (
{!error && data !== null && sorted.length > 0 && visible.length === 0 && (
<p className="text-sm text-muted-foreground" data-testid="search-empty">
{t('search.noResults', { term: query.trim() })}
</p>
)}
{!error && data !== null && visible.length > 0 && (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((instance) => (
<li key={instance.id} className="min-w-0">
<CloudTile
key={`${instance.id}:${instance.logoVersion}`}
+5
View File
@@ -1928,6 +1928,11 @@
"eolPassed": "Nextcloud {name}: Support abgelaufen"
}
},
"search": {
"label": "Clouds durchsuchen",
"placeholder": "Nach Kundenname suchen …",
"noResults": "Keine Cloud passt zu „{term}“."
},
"sort": {
"label": "Sortieren nach",
"name": "Kundenname",
+5
View File
@@ -1928,6 +1928,11 @@
"eolPassed": "Nextcloud {name}: support expired"
}
},
"search": {
"label": "Search clouds",
"placeholder": "Search by customer name …",
"noResults": "No cloud matches “{term}”."
},
"sort": {
"label": "Sort by",
"name": "Customer name",