Merged PR 1959: feat: enhance error handling and validation infrastructure

feat: enhance error handling and validation infrastructure

- Add comprehensive Zod error helper with German localization
- Migrate from deprecated .toPromise() to firstValueFrom()
- Enhance global error handler with ZodError support
- Implement storage features for signal stores with auto-save
- Add comprehensive test coverage for validation scenarios
- Update multiple stores with improved storage integration
- Extend tab management with enhanced navigation patterns
- Add checkout data-access barrel exports
- Update core-storage documentation with usage examples

Major improvements:
- Complete German error message translations for all Zod validation types
- Auto-save with configurable debouncing for signal stores
- Type-safe storage integration with schema validation
- Enhanced entity management with orphan cleanup
- Robust fallback strategies for validation failures

Breaking: Requires Zod validation errors to use new helper

Refs: #5345 #5353

Related work items: #5345, #5353
This commit is contained in:
Lorenz Hilpert
2025-09-25 15:49:01 +00:00
committed by Nino Righi
parent f2490b3421
commit 39a55c9d55
26 changed files with 4038 additions and 1206 deletions

View File

@@ -1 +1,2 @@
export * from './create-esc-abort-controller.helper';
export * from './zod-error.helper';

View File

@@ -0,0 +1,128 @@
import { ZodError, z } from 'zod';
import { extractZodErrorMessage } from './zod-error.helper';
describe('ZodErrorHelper', () => {
describe('extractZodErrorMessage', () => {
it('should return default message for empty issues', () => {
const error = new ZodError([]);
const result = extractZodErrorMessage(error);
expect(result).toBe('Unbekannter Validierungsfehler aufgetreten.');
});
it('should format single invalid_type error', () => {
const schema = z.string();
try {
schema.parse(123);
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('Erwartet: Text, erhalten: Zahl');
}
}
});
it('should format invalid_string error for email validation', () => {
const schema = z.object({
email: z.string().email(),
});
try {
schema.parse({ email: 'invalid-email' });
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('email: Ungültige E-Mail-Adresse');
}
}
});
it('should format too_small error for strings', () => {
const schema = z.object({
name: z.string().min(5),
});
try {
schema.parse({ name: 'ab' });
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('name: Text muss mindestens 5 Zeichen lang sein');
}
}
});
it('should format multiple errors with bullet points', () => {
const schema = z.object({
name: z.string(),
age: z.number(),
});
try {
schema.parse({ name: 123, age: 'not-a-number' });
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toContain('Es sind 2 Validierungsfehler aufgetreten:');
expect(result).toContain('• name: Erwartet: Text, erhalten: Zahl');
expect(result).toContain('• age: Erwartet: Zahl, erhalten: Text');
}
}
});
it('should format nested path correctly', () => {
const schema = z.object({
user: z.object({
profile: z.object({
email: z.string().email(),
}),
}),
});
try {
schema.parse({
user: {
profile: {
email: 'invalid-email',
},
},
});
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('user → profile → email: Ungültige E-Mail-Adresse');
}
}
});
it('should handle array indices in paths', () => {
const schema = z.array(z.string());
try {
schema.parse(['valid', 123, 'also-valid']);
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('[1]: Erwartet: Text, erhalten: Zahl');
}
}
});
it('should handle custom error messages', () => {
const schema = z.string().refine((val) => val === 'specific', {
message: 'Must be exactly "specific"',
});
try {
schema.parse('wrong');
} catch (error) {
if (error instanceof ZodError) {
const result = extractZodErrorMessage(error);
expect(result).toBe('Must be exactly "specific"');
}
}
});
});
});

View File

@@ -0,0 +1,226 @@
import { ZodError, ZodIssue } from 'zod';
/**
* Extracts and formats human-readable error messages from ZodError instances.
*
* This function processes Zod validation errors and transforms them into
* user-friendly messages that can be displayed in UI components.
*
* @param error - The ZodError instance to extract messages from
* @returns A formatted string containing all validation error messages
*
* @example
* ```typescript
* try {
* schema.parse(data);
* } catch (error) {
* if (error instanceof ZodError) {
* const message = extractZodErrorMessage(error);
* // Display message to user
* }
* }
* ```
*/
export function extractZodErrorMessage(error: ZodError): string {
if (!error.issues || error.issues.length === 0) {
return 'Unbekannter Validierungsfehler aufgetreten.';
}
const messages = error.issues.map((issue) => formatZodIssue(issue));
// Remove duplicates and join with line breaks
const uniqueMessages = Array.from(new Set(messages));
if (uniqueMessages.length === 1) {
return uniqueMessages[0];
}
return `Es sind ${uniqueMessages.length} Validierungsfehler aufgetreten:\n\n${uniqueMessages.map(msg => `${msg}`).join('\n')}`;
}
/**
* Formats a single ZodIssue into a human-readable message.
*
* @param issue - The ZodIssue to format
* @returns A formatted error message string
*/
function formatZodIssue(issue: ZodIssue): string {
const fieldPath = formatFieldPath(issue.path);
const fieldPrefix = fieldPath ? `${fieldPath}: ` : '';
switch (issue.code) {
case 'invalid_type':
return `${fieldPrefix}${formatInvalidTypeMessage(issue)}`;
case 'too_small':
return `${fieldPrefix}${formatTooSmallMessage(issue)}`;
case 'too_big':
return `${fieldPrefix}${formatTooBigMessage(issue)}`;
case 'invalid_string':
return `${fieldPrefix}${formatInvalidStringMessage(issue)}`;
case 'unrecognized_keys':
return `${fieldPrefix}Unbekannte Felder: ${issue.keys?.join(', ')}`;
case 'invalid_union':
return `${fieldPrefix}Wert entspricht nicht den erwarteten Optionen`;
case 'invalid_enum_value':
return `${fieldPrefix}Ungültiger Wert. Erlaubt sind: ${issue.options?.join(', ')}`;
case 'invalid_arguments':
return `${fieldPrefix}Ungültige Parameter`;
case 'invalid_return_type':
return `${fieldPrefix}Ungültiger Rückgabetyp`;
case 'invalid_date':
return `${fieldPrefix}Ungültiges Datum`;
case 'invalid_literal':
return `${fieldPrefix}Wert muss exakt '${issue.expected}' sein`;
case 'custom':
return `${fieldPrefix}${issue.message || 'Benutzerdefinierte Validierung fehlgeschlagen'}`;
default:
return `${fieldPrefix}${issue.message || 'Validierungsfehler'}`;
}
}
/**
* Formats a field path array into a human-readable string.
*
* @param path - Array of field path segments
* @returns Formatted path string or empty string if no path
*/
function formatFieldPath(path: (string | number)[]): string {
if (!path || path.length === 0) {
return '';
}
return path
.map((segment) => {
if (typeof segment === 'number') {
return `[${segment}]`;
}
return segment;
})
.join(' → ');
}
/**
* Formats invalid type error messages with German translations.
*/
function formatInvalidTypeMessage(issue: ZodIssue & { expected: string; received: string }): string {
const typeTranslations: Record<string, string> = {
string: 'Text',
number: 'Zahl',
boolean: 'Ja/Nein-Wert',
object: 'Objekt',
array: 'Liste',
date: 'Datum',
undefined: 'undefiniert',
null: 'null',
bigint: 'große Zahl',
function: 'Funktion',
symbol: 'Symbol',
};
const expected = typeTranslations[issue.expected] || issue.expected;
const received = typeTranslations[issue.received] || issue.received;
return `Erwartet: ${expected}, erhalten: ${received}`;
}
/**
* Formats "too small" error messages based on the type.
*/
function formatTooSmallMessage(issue: any): string {
const { type, minimum, inclusive } = issue;
const operator = inclusive ? 'mindestens' : 'mehr als';
switch (type) {
case 'string':
return `Text muss ${operator} ${minimum} Zeichen lang sein`;
case 'number':
case 'bigint':
return `Wert muss ${operator} ${minimum} sein`;
case 'array':
return `Liste muss ${operator} ${minimum} Elemente enthalten`;
case 'set':
return `Set muss ${operator} ${minimum} Elemente enthalten`;
case 'date':
const minDate = typeof minimum === 'bigint' ? new Date(Number(minimum)) : new Date(minimum);
return `Datum muss ${operator} ${minDate.toLocaleDateString('de-DE')} sein`;
default:
return `Wert ist zu klein (min: ${minimum})`;
}
}
/**
* Formats "too big" error messages based on the type.
*/
function formatTooBigMessage(issue: any): string {
const { type, maximum, inclusive } = issue;
const operator = inclusive ? 'höchstens' : 'weniger als';
switch (type) {
case 'string':
return `Text darf ${operator} ${maximum} Zeichen lang sein`;
case 'number':
case 'bigint':
return `Wert darf ${operator} ${maximum} sein`;
case 'array':
return `Liste darf ${operator} ${maximum} Elemente enthalten`;
case 'set':
return `Set darf ${operator} ${maximum} Elemente enthalten`;
case 'date':
const maxDate = typeof maximum === 'bigint' ? new Date(Number(maximum)) : new Date(maximum);
return `Datum darf ${operator} ${maxDate.toLocaleDateString('de-DE')} sein`;
default:
return `Wert ist zu groß (max: ${maximum})`;
}
}
/**
* Formats invalid string error messages based on validation type.
*/
function formatInvalidStringMessage(issue: any): string {
let validation = 'unknown';
if (typeof issue.validation === 'string') {
validation = issue.validation;
} else if (typeof issue.validation === 'object' && issue.validation) {
if ('includes' in issue.validation) {
validation = 'includes';
} else if ('startsWith' in issue.validation) {
validation = 'startsWith';
} else if ('endsWith' in issue.validation) {
validation = 'endsWith';
} else {
validation = Object.keys(issue.validation)[0] || 'unknown';
}
}
const validationMessages: Record<string, string> = {
email: 'Ungültige E-Mail-Adresse',
url: 'Ungültige URL',
uuid: 'Ungültige UUID',
cuid: 'Ungültige CUID',
cuid2: 'Ungültige CUID2',
ulid: 'Ungültige ULID',
regex: 'Format entspricht nicht dem erwarteten Muster',
datetime: 'Ungültiges Datum/Zeit-Format',
ip: 'Ungültige IP-Adresse',
emoji: 'Muss ein Emoji sein',
includes: 'Text muss bestimmte Zeichen enthalten',
startsWith: 'Text muss mit bestimmten Zeichen beginnen',
endsWith: 'Text muss mit bestimmten Zeichen enden',
length: 'Text hat eine ungültige Länge',
};
return validationMessages[validation] || `Ungültiges Format (${validation})`;
}