feat(nextcloud-status): Cloud-Formular zeigt nur deutsche Meldungen, Bildadresse auch per http (j9f)
- Vorabpruefung im Browser, API-Kennungen werden zu de/en-Texten - Beschriftung Bildadresse mit neuem Hinweis zu http und https Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@ import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testi
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NextcloudInstance } from '@/lib/nextcloud-status-api';
|
||||
import { NextcloudFormError, type NextcloudInstance } from '@/lib/nextcloud-status-api';
|
||||
import de from '@/messages/de.json';
|
||||
import { CloudForm } from './CloudForm';
|
||||
|
||||
@@ -66,11 +66,41 @@ describe('CloudForm', () => {
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('verlangt Kundenname und Adresse', async () => {
|
||||
it('verlangt einen Kundennamen, ohne die API zu fragen', async () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte füllen Sie Kundenname und Adresse aus.',
|
||||
'Bitte geben Sie einen Kundennamen ein.',
|
||||
);
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prüft die Adresse der Cloud vorab auf Deutsch', async () => {
|
||||
setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'cloud.example.de' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.',
|
||||
);
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prüft die Bildadresse vorab auf Deutsch', async () => {
|
||||
setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' }));
|
||||
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||
target: { value: 'ftp://x' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte geben Sie eine gültige Bildadresse mit http:// oder https:// ein.',
|
||||
);
|
||||
expect(api.createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -110,23 +140,39 @@ describe('CloudForm', () => {
|
||||
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
|
||||
});
|
||||
|
||||
it('sendet eine https-Bildadresse und weist darauf hin', async () => {
|
||||
it('sendet eine http-Bildadresse unverändert an die API', async () => {
|
||||
api.createInstance.mockResolvedValue(EXISTING);
|
||||
setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }),
|
||||
);
|
||||
expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument();
|
||||
fireEvent.change(
|
||||
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }),
|
||||
{
|
||||
target: { value: 'https://logo.example.de/a.png' },
|
||||
},
|
||||
fireEvent.click(screen.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' }));
|
||||
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||
target: { value: 'http://logo.example.de/a.png' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() =>
|
||||
expect(api.createInstance).toHaveBeenCalledWith({
|
||||
customerName: 'Neu',
|
||||
baseUrl: 'https://cloud.neu.de',
|
||||
logoUrl: 'http://logo.example.de/a.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sendet eine https-Bildadresse und erklärt, wie Bilder geholt werden', async () => {
|
||||
api.createInstance.mockResolvedValue(EXISTING);
|
||||
setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' }));
|
||||
expect(screen.getByText(/holt Tessera beim Speichern einmalig ab/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||
target: { value: 'https://logo.example.de/a.png' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
await waitFor(() =>
|
||||
expect(api.createInstance).toHaveBeenCalledWith({
|
||||
@@ -147,6 +193,18 @@ describe('CloudForm', () => {
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
||||
});
|
||||
|
||||
it('lehnt eine Datei mit nicht erlaubtem Typ sofort ab', async () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||
const svg = new File(['<svg/>'], 'logo.svg', { type: 'image/svg+xml' });
|
||||
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||
target: { files: [svg] },
|
||||
});
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ändert eine vorhandene Cloud', async () => {
|
||||
api.updateInstance.mockResolvedValue(EXISTING);
|
||||
const props = setup(EXISTING);
|
||||
@@ -185,18 +243,55 @@ describe('CloudForm', () => {
|
||||
expect(props.onDeleted).toHaveBeenCalledWith('i1');
|
||||
});
|
||||
|
||||
it('zeigt die Fehlermeldung der API im Formular', async () => {
|
||||
function fillValid() {
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
|
||||
target: { value: 'https://cloud.neu.de' },
|
||||
});
|
||||
}
|
||||
|
||||
it('übersetzt eine Fehlerkennung der API in den deutschen Text', async () => {
|
||||
api.createInstance.mockRejectedValue(
|
||||
new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'),
|
||||
new NextcloudFormError(400, 'logoFetchInternal', 'Diese Bildadresse ist nur intern …'),
|
||||
);
|
||||
const props = setup();
|
||||
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
|
||||
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } });
|
||||
fillValid();
|
||||
fireEvent.click(screen.getByLabelText('Bildadresse', { selector: 'input[type="radio"]' }));
|
||||
fireEvent.change(screen.getByLabelText('Bildadresse', { selector: 'input[type="url"]' }), {
|
||||
target: { value: 'http://192.168.1.5/logo.png' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Bitte geben Sie eine gültige Adresse',
|
||||
);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent('Bild hochladen');
|
||||
expect(alert).toHaveTextContent('nur intern erreichbar');
|
||||
expect(props.onSaved).not.toHaveBeenCalled();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('zeigt bei englischem Servertext ohne Kennung nur den allgemeinen deutschen Text', async () => {
|
||||
api.createInstance.mockRejectedValue(
|
||||
new NextcloudFormError(400, null, 'logoUrl must be a URL address'),
|
||||
);
|
||||
setup();
|
||||
fillValid();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Die Cloud konnte nicht gespeichert werden.',
|
||||
);
|
||||
expect(screen.queryByText(/must be a URL address/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('413 beim Hochladen zeigt den Text zur Dateigröße', async () => {
|
||||
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
|
||||
api.uploadLogo.mockRejectedValue(new NextcloudFormError(413, null, 'File too large'));
|
||||
setup();
|
||||
fillValid();
|
||||
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
|
||||
const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' });
|
||||
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
|
||||
target: { files: [file] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
cloudFormErrorKey,
|
||||
isAllowedLogoFileType,
|
||||
validateCloudForm,
|
||||
} from '@/components/nextcloud-status/cloud-form-errors';
|
||||
import {
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
@@ -38,8 +43,11 @@ function initialMode(instance: NextcloudInstance | null): LogoMode {
|
||||
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
|
||||
* zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein
|
||||
* der ModuleGuard der API. Das Logo ist entweder ein Upload (die API prueft den
|
||||
* Typ an den Bytes) oder eine https-Bildadresse, die der Browser selbst laedt.
|
||||
* Anlegen wartet auf die erste Pruefung („Wird geprueft …“).
|
||||
* Typ an den Bytes) oder eine Bildadresse: https laedt der Browser selbst, ein
|
||||
* http-Bild holt Tessera beim Speichern einmalig ab und speichert es wie einen
|
||||
* Upload (quick-261008-j9f). Anlegen wartet auf die erste Pruefung („Wird
|
||||
* geprueft …“). Fehler werden nie als Servertext gezeigt: das Formular prueft
|
||||
* vorab, und Fehler der API werden ueber ihre Kennung uebersetzt (D-05).
|
||||
*/
|
||||
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
|
||||
const t = useTranslations('nextcloudStatus.form');
|
||||
@@ -49,7 +57,8 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Uebersetzungsschluessel unter `nextcloudStatus.form` — nie ein Text vom Server.
|
||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||
|
||||
const currentLogo =
|
||||
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
|
||||
@@ -61,26 +70,32 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const original = e.target.files?.[0] ?? null;
|
||||
if (original && !isAllowedLogoFileType(original.type)) {
|
||||
pendingFile.current = Promise.resolve(null);
|
||||
setErrorKey('errors.logoFileInvalid');
|
||||
return;
|
||||
}
|
||||
const shrunk = original ? shrinkImage(original) : Promise.resolve(null);
|
||||
pendingFile.current = shrunk.then((picked) => {
|
||||
if (picked && picked.size > LOGO_MAX_BYTES) {
|
||||
setError(t('logoFileTooLarge'));
|
||||
setErrorKey('errors.logoFileTooLarge');
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
setErrorKey(null);
|
||||
return picked;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const name = customerName.trim();
|
||||
const url = baseUrl.trim();
|
||||
if (!name || !url) {
|
||||
setError(t('required'));
|
||||
setErrorKey(null);
|
||||
const invalid = validateCloudForm({ customerName, baseUrl, logoMode: mode, logoUrl });
|
||||
if (invalid) {
|
||||
setErrorKey(`errors.${invalid}`);
|
||||
return;
|
||||
}
|
||||
const name = customerName.trim();
|
||||
const url = baseUrl.trim();
|
||||
setSaving(true);
|
||||
try {
|
||||
const upload = mode === 'upload' ? await pendingFile.current : null;
|
||||
@@ -106,7 +121,7 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t('saveError'));
|
||||
setErrorKey(cloudFormErrorKey(err, 'saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -114,14 +129,14 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!instance) return;
|
||||
setError(null);
|
||||
setErrorKey(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteInstance(instance.id);
|
||||
onDeleted(instance.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error && err.message ? err.message : t('deleteError'));
|
||||
setErrorKey(cloudFormErrorKey(err, 'deleteError'));
|
||||
setConfirmDelete(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -147,12 +162,12 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
<p className="text-sm text-foreground">
|
||||
{t('deleteConfirm', { name: instance.customerName })}
|
||||
</p>
|
||||
{error && (
|
||||
{errorKey && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
{t(errorKey)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
@@ -267,12 +282,12 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{error && (
|
||||
{errorKey && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
{t(errorKey)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user