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
@@ -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",