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:
@@ -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}`}
|
||||
|
||||
@@ -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