feat(nextcloud-status): Suche nach Kundenname, kompaktere Kacheln, Adresse in voller Laenge
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,6 +274,16 @@ export default function NextcloudStatusPage() {
|
||||
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
|
||||
</p>
|
||||
{data.instances.length > 0 && (
|
||||
<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
|
||||
@@ -281,6 +298,7 @@ export default function NextcloudStatusPage() {
|
||||
))}
|
||||
</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}`}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user