feat(settings): Akzentfarbe zusätzlich als Hex-Code eingebbar
- normalizeHexColor() (apps/web/src/lib/color.ts) normalisiert Hex-Eingaben (fuehrendes # optional, 3-stellige Kurzform, Gross-/Kleinschreibung, Leerraum) - Kontoformular: Hex-Textfeld neben dem Farbwaehler, beide bidirektional synchron, ersetzt den reinen Anzeige-Span - Ungueltiger Text: aria-invalid + roter Rand + Fehlertext, "Farbe speichern" gesperrt; Zuruecksetzen setzt beides auf #ffed00 - i18n: settings.account.accentColorHex/accentColorHexInvalid in de.json und en.json - Unit-Tests: color.test.ts (9 Faelle), account-settings-form.test.tsx (7 Faelle) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AccountSettingsForm } from './account-settings-form';
|
||||
|
||||
/**
|
||||
* account-settings-form.test — Akzentfarbe Hex-Eingabe (quick-260917-gsh).
|
||||
* next-intl-Mock de.json-gestuetzt (Muster: desktop-app-settings.test.tsx),
|
||||
* `@/lib/auth-actions` per vi.hoisted + vi.mock ersetzt.
|
||||
*/
|
||||
vi.mock('next-intl', async () => {
|
||||
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
|
||||
const lookup = (path: string): string | undefined =>
|
||||
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
useTranslations:
|
||||
(ns?: string) =>
|
||||
(key: string, values?: Record<string, unknown>) => {
|
||||
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
|
||||
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
|
||||
},
|
||||
useLocale: () => 'de',
|
||||
};
|
||||
});
|
||||
|
||||
const { fetchCurrentUser, updateAccentColorAction, changePasswordAction, uploadAvatarAction, deleteAvatarAction } =
|
||||
vi.hoisted(() => ({
|
||||
fetchCurrentUser: vi.fn(),
|
||||
updateAccentColorAction: vi.fn(),
|
||||
changePasswordAction: vi.fn(),
|
||||
uploadAvatarAction: vi.fn(),
|
||||
deleteAvatarAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-actions', () => ({
|
||||
fetchCurrentUser,
|
||||
updateAccentColorAction,
|
||||
changePasswordAction,
|
||||
uploadAvatarAction,
|
||||
deleteAvatarAction,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('AccountSettingsForm — Akzentfarbe Hex-Eingabe', () => {
|
||||
it('prefills the hex textfield and the color picker from the loaded user', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#123456',
|
||||
});
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
|
||||
const hexInput = (await screen.findByRole('textbox', {
|
||||
name: 'Hex-Code',
|
||||
})) as HTMLInputElement;
|
||||
expect(hexInput.value).toBe('#123456');
|
||||
|
||||
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
expect(colorPicker.value).toBe('#123456');
|
||||
});
|
||||
|
||||
it('accepts a valid hex value typed without # and syncs the color picker', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#123456',
|
||||
});
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
const hexInput = (await screen.findByRole('textbox', {
|
||||
name: 'Hex-Code',
|
||||
})) as HTMLInputElement;
|
||||
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(hexInput, { target: { value: 'FFED00' } });
|
||||
|
||||
expect(colorPicker.value).toBe('#ffed00');
|
||||
expect(hexInput).toHaveAttribute('aria-invalid', 'false');
|
||||
expect(screen.getByText('Farbe speichern')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('marks an invalid hex value, blocks save, and keeps the last valid color picker value', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#123456',
|
||||
});
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
const hexInput = (await screen.findByRole('textbox', {
|
||||
name: 'Hex-Code',
|
||||
})) as HTMLInputElement;
|
||||
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(hexInput, { target: { value: '#ggg' } });
|
||||
|
||||
expect(hexInput).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(hexInput.className).toContain('border-destructive');
|
||||
expect(
|
||||
screen.getByText('Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Farbe speichern')).toBeDisabled();
|
||||
expect(colorPicker.value).toBe('#123456');
|
||||
});
|
||||
|
||||
it('syncs the hex textfield when the color picker changes', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#123456',
|
||||
});
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
await screen.findByRole('textbox', { name: 'Hex-Code' });
|
||||
const hexInput = screen.getByRole('textbox', { name: 'Hex-Code' }) as HTMLInputElement;
|
||||
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.change(colorPicker, { target: { value: '#00ff00' } });
|
||||
|
||||
expect(hexInput.value).toBe('#00ff00');
|
||||
});
|
||||
|
||||
it('normalizes the hex textfield to canonical form on blur', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#123456',
|
||||
});
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
const hexInput = (await screen.findByRole('textbox', {
|
||||
name: 'Hex-Code',
|
||||
})) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(hexInput, { target: { value: '#fe0' } });
|
||||
fireEvent.blur(hexInput);
|
||||
|
||||
expect(hexInput.value).toBe('#ffee00');
|
||||
});
|
||||
|
||||
it('resets both the hex textfield and the color picker on "Zurücksetzen"', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#123456',
|
||||
});
|
||||
updateAccentColorAction.mockResolvedValue({ success: true });
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
const hexInput = (await screen.findByRole('textbox', {
|
||||
name: 'Hex-Code',
|
||||
})) as HTMLInputElement;
|
||||
const colorPicker = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
|
||||
fireEvent.click(screen.getByText('Zurücksetzen'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hexInput.value).toBe('#ffed00');
|
||||
});
|
||||
expect(colorPicker.value).toBe('#ffed00');
|
||||
expect(updateAccentColorAction).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('saves the normalized color and shows the success message', async () => {
|
||||
fetchCurrentUser.mockResolvedValue({
|
||||
id: 'u1',
|
||||
username: 'max',
|
||||
displayName: 'Max',
|
||||
role: 'USER',
|
||||
tenantId: 't1',
|
||||
isLocalUser: true,
|
||||
hasAvatar: false,
|
||||
accentColor: '#ffed00',
|
||||
});
|
||||
updateAccentColorAction.mockResolvedValue({ success: true });
|
||||
|
||||
render(<AccountSettingsForm />);
|
||||
await screen.findByRole('textbox', { name: 'Hex-Code' });
|
||||
|
||||
fireEvent.click(screen.getByText('Farbe speichern'));
|
||||
|
||||
expect(await screen.findByText('Akzentfarbe erfolgreich gespeichert.')).toBeInTheDocument();
|
||||
expect(updateAccentColorAction).toHaveBeenCalledWith('#ffed00');
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/auth-actions';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { BRAND_YELLOW } from '@/components/brand/brand';
|
||||
import { normalizeHexColor } from '@/lib/color';
|
||||
|
||||
const DEFAULT_ACCENT = BRAND_YELLOW;
|
||||
|
||||
@@ -38,6 +39,8 @@ export function AccountSettingsForm() {
|
||||
|
||||
// Accent color state
|
||||
const [accentColor, setAccentColor] = useState<string>(DEFAULT_ACCENT);
|
||||
const [hexInput, setHexInput] = useState<string>(DEFAULT_ACCENT);
|
||||
const isHexValid = normalizeHexColor(hexInput) !== null;
|
||||
const [accentSuccess, setAccentSuccess] = useState(false);
|
||||
const [accentError, setAccentError] = useState<string | null>(null);
|
||||
const [isAccentPending, startAccentTransition] = useTransition();
|
||||
@@ -48,7 +51,9 @@ export function AccountSettingsForm() {
|
||||
setIsLocalUser(u.isLocalUser ?? false);
|
||||
setUsername(u.displayName ?? u.username ?? '');
|
||||
setHasAvatar(!!u.hasAvatar);
|
||||
setAccentColor(u.accentColor ?? DEFAULT_ACCENT);
|
||||
const c = u.accentColor ?? DEFAULT_ACCENT;
|
||||
setAccentColor(c);
|
||||
setHexInput(c);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
@@ -113,6 +118,7 @@ export function AccountSettingsForm() {
|
||||
}
|
||||
|
||||
function handleSaveAccentColor() {
|
||||
if (!isHexValid) return;
|
||||
setAccentSuccess(false);
|
||||
setAccentError(null);
|
||||
startAccentTransition(async () => {
|
||||
@@ -128,6 +134,7 @@ export function AccountSettingsForm() {
|
||||
|
||||
function handleResetAccentColor() {
|
||||
setAccentColor(DEFAULT_ACCENT);
|
||||
setHexInput(DEFAULT_ACCENT);
|
||||
setAccentSuccess(false);
|
||||
setAccentError(null);
|
||||
startAccentTransition(async () => {
|
||||
@@ -212,14 +219,41 @@ export function AccountSettingsForm() {
|
||||
{t('account.accentColorLabel')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mb-4">{t('account.accentColorHelp')}</p>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<input
|
||||
type="color"
|
||||
value={accentColor}
|
||||
onChange={(e) => setAccentColor(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAccentColor(e.target.value);
|
||||
setHexInput(e.target.value);
|
||||
}}
|
||||
className="h-10 w-16 cursor-pointer rounded-md border border-input p-1"
|
||||
/>
|
||||
<span className="font-mono text-sm text-muted-foreground">{accentColor}</span>
|
||||
<input
|
||||
id="accentColorHex"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
maxLength={7}
|
||||
placeholder={DEFAULT_ACCENT}
|
||||
value={hexInput}
|
||||
aria-label={t('account.accentColorHex')}
|
||||
aria-invalid={!isHexValid}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
setHexInput(raw);
|
||||
const n = normalizeHexColor(raw);
|
||||
if (n) setAccentColor(n);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const n = normalizeHexColor(hexInput);
|
||||
if (n) setHexInput(n);
|
||||
}}
|
||||
className={`h-10 w-28 rounded-md border bg-background px-3 py-2 font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
isHexValid ? 'border-input' : 'border-destructive'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetAccentColor}
|
||||
@@ -229,6 +263,9 @@ export function AccountSettingsForm() {
|
||||
{t('account.accentColorReset')}
|
||||
</button>
|
||||
</div>
|
||||
{!isHexValid && (
|
||||
<p className="text-xs text-destructive mb-3">{t('account.accentColorHexInvalid')}</p>
|
||||
)}
|
||||
{accentSuccess && (
|
||||
<div className="rounded-md bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 px-4 py-3 text-sm text-green-800 dark:text-green-200 mb-3">
|
||||
{t('account.accentColorSuccess')}
|
||||
@@ -242,7 +279,7 @@ export function AccountSettingsForm() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveAccentColor}
|
||||
disabled={isAccentPending}
|
||||
disabled={isAccentPending || !isHexValid}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{isAccentPending ? '...' : t('account.accentColorSave')}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeHexColor } from './color';
|
||||
|
||||
/**
|
||||
* color.test — Normalisierung der Hex-Eingabe der Akzentfarbe
|
||||
* (quick-260917-gsh).
|
||||
*/
|
||||
describe('normalizeHexColor', () => {
|
||||
it('accepts a canonical six-digit hex value with leading #', () => {
|
||||
expect(normalizeHexColor('#ffed00')).toBe('#ffed00');
|
||||
});
|
||||
|
||||
it('accepts a six-digit value without # and uppercase letters', () => {
|
||||
expect(normalizeHexColor('FFED00')).toBe('#ffed00');
|
||||
});
|
||||
|
||||
it('expands a three-digit shorthand', () => {
|
||||
expect(normalizeHexColor('#fe0')).toBe('#ffee00');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace and lowercases mixed case', () => {
|
||||
expect(normalizeHexColor(' #FfEd00 ')).toBe('#ffed00');
|
||||
});
|
||||
|
||||
it('rejects non-hex characters', () => {
|
||||
expect(normalizeHexColor('#ggg')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the wrong digit count (5)', () => {
|
||||
expect(normalizeHexColor('#12345')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an empty string', () => {
|
||||
expect(normalizeHexColor('')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a lone hash', () => {
|
||||
expect(normalizeHexColor('#')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the wrong digit count (7)', () => {
|
||||
expect(normalizeHexColor('#1234567')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Normalisierung der Hex-Eingabe der persoenlichen Akzentfarbe
|
||||
* (Einstellungen → Konto, quick-260917-gsh).
|
||||
*
|
||||
* `PATCH /users/me/accent-color` verlangt serverseitig `/^#[0-9a-fA-F]{6}$/` —
|
||||
* diese Funktion bringt Roheingaben (mit/ohne `#`, Kurzform, Gross-/
|
||||
* Kleinschreibung, umgebender Leerraum) in genau diese kanonische Form
|
||||
* oder liefert `null`, wenn das nicht moeglich ist.
|
||||
*/
|
||||
|
||||
const HEX_PATTERN = /^[0-9a-f]{3}$|^[0-9a-f]{6}$/i;
|
||||
|
||||
export function normalizeHexColor(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed;
|
||||
|
||||
if (!HEX_PATTERN.test(withoutHash)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expanded =
|
||||
withoutHash.length === 3
|
||||
? withoutHash
|
||||
.split('')
|
||||
.map((ch) => ch + ch)
|
||||
.join('')
|
||||
: withoutHash;
|
||||
|
||||
return `#${expanded.toLowerCase()}`;
|
||||
}
|
||||
@@ -149,6 +149,8 @@
|
||||
"accentColorSave": "Farbe speichern",
|
||||
"accentColorSuccess": "Akzentfarbe erfolgreich gespeichert.",
|
||||
"accentColorError": "Speichern fehlgeschlagen.",
|
||||
"accentColorHex": "Hex-Code",
|
||||
"accentColorHexInvalid": "Ungültiger Farbwert. Bitte geben Sie sechs Hexadezimalzeichen ein, z. B. #ffed00.",
|
||||
"deleteAvatarCta": "Bild löschen",
|
||||
"deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.",
|
||||
"deleteAvatarError": "Löschen fehlgeschlagen."
|
||||
|
||||
@@ -149,6 +149,8 @@
|
||||
"accentColorSave": "Save color",
|
||||
"accentColorSuccess": "Accent color saved successfully.",
|
||||
"accentColorError": "Failed to save color.",
|
||||
"accentColorHex": "Hex code",
|
||||
"accentColorHexInvalid": "Invalid color value. Please enter six hexadecimal characters, e.g. #ffed00.",
|
||||
"deleteAvatarCta": "Delete picture",
|
||||
"deleteAvatarSuccess": "Profile picture deleted successfully.",
|
||||
"deleteAvatarError": "Failed to delete picture."
|
||||
|
||||
Reference in New Issue
Block a user