@umituz/web-design-system 1.0.4 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -0
- package/package.json +18 -5
- package/src/global.d.ts +20 -0
- package/src/index.ts +3 -0
- package/src/infrastructure/error/ErrorBoundary.tsx +258 -0
- package/src/infrastructure/error/ErrorDisplay.tsx +346 -0
- package/src/infrastructure/error/SuspenseWrapper.tsx +134 -0
- package/src/infrastructure/error/index.ts +5 -0
- package/src/infrastructure/performance/index.ts +6 -0
- package/src/infrastructure/performance/useLazyLoading.ts +342 -0
- package/src/infrastructure/performance/useMemoryOptimization.ts +293 -0
- package/src/infrastructure/performance/usePerformanceMonitor.ts +158 -0
- package/src/infrastructure/security/index.ts +10 -0
- package/src/infrastructure/security/security-config.ts +171 -0
- package/src/infrastructure/security/useFormValidation.ts +216 -0
- package/src/infrastructure/security/validation.ts +242 -0
- package/src/infrastructure/utils/cn.util.ts +7 -7
- package/src/infrastructure/utils/index.ts +1 -1
- package/src/presentation/atoms/Input.tsx +1 -1
- package/src/presentation/atoms/Slider.tsx +1 -1
- package/src/presentation/atoms/Text.tsx +1 -1
- package/src/presentation/atoms/Tooltip.tsx +2 -2
- package/src/presentation/hooks/index.ts +2 -1
- package/src/presentation/hooks/useClickOutside.ts +1 -1
- package/src/presentation/molecules/CheckboxGroup.tsx +1 -1
- package/src/presentation/molecules/FormField.tsx +2 -2
- package/src/presentation/molecules/InputGroup.tsx +1 -1
- package/src/presentation/molecules/RadioGroup.tsx +1 -1
- package/src/presentation/organisms/Alert.tsx +1 -1
- package/src/presentation/organisms/Card.tsx +1 -1
- package/src/presentation/organisms/Footer.tsx +99 -0
- package/src/presentation/organisms/Navbar.tsx +1 -1
- package/src/presentation/organisms/Table.tsx +1 -1
- package/src/presentation/organisms/index.ts +3 -0
- package/src/presentation/templates/Form.tsx +2 -2
- package/src/presentation/templates/List.tsx +1 -1
- package/src/presentation/templates/PageHeader.tsx +78 -0
- package/src/presentation/templates/PageLayout.tsx +38 -0
- package/src/presentation/templates/ProjectSkeleton.tsx +35 -0
- package/src/presentation/templates/Section.tsx +1 -1
- package/src/presentation/templates/index.ts +9 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface PerformanceMetrics {
|
|
4
|
+
renderTime: number;
|
|
5
|
+
mountTime: number;
|
|
6
|
+
updateCount: number;
|
|
7
|
+
lastRenderTime: number;
|
|
8
|
+
averageRenderTime: number;
|
|
9
|
+
memoryUsage?: number;
|
|
10
|
+
componentName?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PerformanceConfig {
|
|
14
|
+
trackRenders?: boolean;
|
|
15
|
+
trackMemory?: boolean;
|
|
16
|
+
sampleRate?: number;
|
|
17
|
+
componentName?: string;
|
|
18
|
+
onMetricsUpdate?: (metrics: PerformanceMetrics) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const usePerformanceMonitor = (config: PerformanceConfig = {}) => {
|
|
22
|
+
const {
|
|
23
|
+
trackRenders = true,
|
|
24
|
+
trackMemory = false,
|
|
25
|
+
sampleRate = 1.0,
|
|
26
|
+
componentName = 'Unknown',
|
|
27
|
+
onMetricsUpdate
|
|
28
|
+
} = config;
|
|
29
|
+
|
|
30
|
+
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
|
31
|
+
renderTime: 0,
|
|
32
|
+
mountTime: 0,
|
|
33
|
+
updateCount: 0,
|
|
34
|
+
lastRenderTime: 0,
|
|
35
|
+
averageRenderTime: 0,
|
|
36
|
+
componentName
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const mountTimeRef = useRef<number>(0);
|
|
40
|
+
const renderStartRef = useRef<number>(0);
|
|
41
|
+
const renderTimesRef = useRef<number[]>([]);
|
|
42
|
+
const updateCountRef = useRef<number>(0);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
mountTimeRef.current = performance.now();
|
|
46
|
+
renderStartRef.current = performance.now();
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
// Component unmount
|
|
50
|
+
};
|
|
51
|
+
}, [componentName]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!trackRenders || Math.random() > sampleRate) return;
|
|
55
|
+
|
|
56
|
+
const renderEndTime = performance.now();
|
|
57
|
+
const renderTime = renderEndTime - renderStartRef.current;
|
|
58
|
+
|
|
59
|
+
renderTimesRef.current.push(renderTime);
|
|
60
|
+
updateCountRef.current += 1;
|
|
61
|
+
|
|
62
|
+
if (renderTimesRef.current.length > 50) {
|
|
63
|
+
renderTimesRef.current = renderTimesRef.current.slice(-50);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const averageRenderTime = renderTimesRef.current.reduce((sum, time) => sum + time, 0) / renderTimesRef.current.length;
|
|
67
|
+
|
|
68
|
+
let memoryUsage: number | undefined;
|
|
69
|
+
if (trackMemory && 'memory' in performance) {
|
|
70
|
+
memoryUsage = (performance as { memory?: { usedJSHeapSize: number } }).memory?.usedJSHeapSize;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const newMetrics: PerformanceMetrics = {
|
|
74
|
+
renderTime,
|
|
75
|
+
mountTime: renderEndTime - mountTimeRef.current,
|
|
76
|
+
updateCount: updateCountRef.current,
|
|
77
|
+
lastRenderTime: renderTime,
|
|
78
|
+
averageRenderTime,
|
|
79
|
+
memoryUsage,
|
|
80
|
+
componentName
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
setMetrics(newMetrics);
|
|
84
|
+
onMetricsUpdate?.(newMetrics);
|
|
85
|
+
|
|
86
|
+
renderStartRef.current = performance.now();
|
|
87
|
+
|
|
88
|
+
if (import.meta.env.DEV) {
|
|
89
|
+
if (renderTime > 16) {
|
|
90
|
+
console.warn(`[Performance] Slow render detected: ${renderTime.toFixed(2)}ms`);
|
|
91
|
+
}
|
|
92
|
+
if (updateCountRef.current % 10 === 0) {
|
|
93
|
+
console.log({
|
|
94
|
+
renders: updateCountRef.current,
|
|
95
|
+
avgRenderTime: averageRenderTime.toFixed(2) + 'ms',
|
|
96
|
+
lastRenderTime: renderTime.toFixed(2) + 'ms'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}, [trackRenders, sampleRate, trackMemory, componentName, onMetricsUpdate]);
|
|
101
|
+
|
|
102
|
+
const resetMetrics = useCallback(() => {
|
|
103
|
+
renderTimesRef.current = [];
|
|
104
|
+
updateCountRef.current = 0;
|
|
105
|
+
mountTimeRef.current = performance.now();
|
|
106
|
+
renderStartRef.current = performance.now();
|
|
107
|
+
|
|
108
|
+
setMetrics({
|
|
109
|
+
renderTime: 0,
|
|
110
|
+
mountTime: 0,
|
|
111
|
+
updateCount: 0,
|
|
112
|
+
lastRenderTime: 0,
|
|
113
|
+
averageRenderTime: 0,
|
|
114
|
+
componentName
|
|
115
|
+
});
|
|
116
|
+
}, [componentName]);
|
|
117
|
+
|
|
118
|
+
const getPerformanceReport = useCallback(() => {
|
|
119
|
+
return {
|
|
120
|
+
...metrics,
|
|
121
|
+
renderTimes: [...renderTimesRef.current],
|
|
122
|
+
isSlowComponent: metrics.averageRenderTime > 16,
|
|
123
|
+
performanceGrade: metrics.averageRenderTime < 5 ? 'A' :
|
|
124
|
+
metrics.averageRenderTime < 10 ? 'B' :
|
|
125
|
+
metrics.averageRenderTime < 16 ? 'C' : 'D'
|
|
126
|
+
};
|
|
127
|
+
}, [metrics]);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
metrics,
|
|
131
|
+
resetMetrics,
|
|
132
|
+
getPerformanceReport
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const useRenderPerformance = (componentName?: string) => {
|
|
137
|
+
const renderStartTime = useRef<number>(0);
|
|
138
|
+
const [renderStats, setRenderStats] = useState<{
|
|
139
|
+
lastRenderTime: number;
|
|
140
|
+
renderCount: number;
|
|
141
|
+
}>({
|
|
142
|
+
lastRenderTime: 0,
|
|
143
|
+
renderCount: 0
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const startTime = performance.now();
|
|
147
|
+
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
setRenderStats(prev => ({
|
|
150
|
+
lastRenderTime: performance.now() - startTime,
|
|
151
|
+
renderCount: prev.renderCount + 1
|
|
152
|
+
}));
|
|
153
|
+
|
|
154
|
+
renderStartTime.current = performance.now();
|
|
155
|
+
}, [componentName, renderStats, startTime]);
|
|
156
|
+
|
|
157
|
+
return renderStats;
|
|
158
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { ValidationConfig } from './validation';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Content Security Policy configuration
|
|
5
|
+
* Helps prevent XSS attacks by controlling resource loading
|
|
6
|
+
*/
|
|
7
|
+
export const CSP_CONFIG = {
|
|
8
|
+
directives: {
|
|
9
|
+
'default-src': ["'self'"],
|
|
10
|
+
'script-src': ["'self'", "'unsafe-inline'"],
|
|
11
|
+
'style-src': ["'self'", "'unsafe-inline'"],
|
|
12
|
+
'img-src': ["'self'", "data:", "https:"],
|
|
13
|
+
'connect-src': ["'self'", "https:", "wss:"],
|
|
14
|
+
'font-src': ["'self'", "https:", "data:"],
|
|
15
|
+
'object-src': ["'none'"],
|
|
16
|
+
'media-src': ["'self'", "https:"],
|
|
17
|
+
'frame-src': ["'none'"],
|
|
18
|
+
'worker-src': ["'self'"],
|
|
19
|
+
'child-src': ["'none'"],
|
|
20
|
+
'form-action': ["'self'"],
|
|
21
|
+
'base-uri': ["'self'"],
|
|
22
|
+
'manifest-src': ["'self'"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Validation configurations for different content types
|
|
29
|
+
*/
|
|
30
|
+
export const VALIDATION_CONFIGS: Record<string, ValidationConfig> = {
|
|
31
|
+
PLAIN_TEXT: {
|
|
32
|
+
allowHtml: false,
|
|
33
|
+
stripTags: true,
|
|
34
|
+
allowedTags: [],
|
|
35
|
+
allowedAttributes: []
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
RICH_TEXT: {
|
|
39
|
+
allowHtml: true,
|
|
40
|
+
stripTags: false,
|
|
41
|
+
allowedTags: ['p', 'br', 'strong', 'em', 'u', 'ol', 'ul', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
|
42
|
+
allowedAttributes: ['class']
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
LINK_CONTENT: {
|
|
46
|
+
allowHtml: true,
|
|
47
|
+
stripTags: false,
|
|
48
|
+
allowedTags: ['a', 'p', 'br', 'strong', 'em'],
|
|
49
|
+
allowedAttributes: ['href', 'title', 'target']
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
USER_PROFILE: {
|
|
53
|
+
allowHtml: false,
|
|
54
|
+
stripTags: true,
|
|
55
|
+
allowedTags: [],
|
|
56
|
+
allowedAttributes: []
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
COMMENT: {
|
|
60
|
+
allowHtml: true,
|
|
61
|
+
stripTags: false,
|
|
62
|
+
allowedTags: ['p', 'br', 'strong', 'em'],
|
|
63
|
+
allowedAttributes: []
|
|
64
|
+
}
|
|
65
|
+
} as const;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* File upload security settings
|
|
69
|
+
*/
|
|
70
|
+
export const FILE_SECURITY_CONFIG = {
|
|
71
|
+
allowedMimeTypes: {
|
|
72
|
+
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
|
73
|
+
audio: ['audio/mpeg', 'audio/wav', 'audio/mp4', 'audio/flac'],
|
|
74
|
+
video: ['video/mp4', 'video/webm', 'video/quicktime'],
|
|
75
|
+
document: ['application/pdf', 'text/plain'],
|
|
76
|
+
avatar: ['image/jpeg', 'image/png', 'image/webp']
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
maxFileSizes: {
|
|
80
|
+
image: 10 * 1024 * 1024,
|
|
81
|
+
audio: 100 * 1024 * 1024,
|
|
82
|
+
video: 500 * 1024 * 1024,
|
|
83
|
+
document: 5 * 1024 * 1024,
|
|
84
|
+
avatar: 2 * 1024 * 1024
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
blockedExtensions: [
|
|
88
|
+
'.exe', '.bat', '.cmd', '.com', '.pif', '.scr', '.vbs', '.js', '.jar',
|
|
89
|
+
'.app', '.deb', '.pkg', '.dmg', '.rpm', '.msi', '.apk', '.ipa'
|
|
90
|
+
],
|
|
91
|
+
|
|
92
|
+
blockedPatterns: [
|
|
93
|
+
/^\./,
|
|
94
|
+
/\.\.$/,
|
|
95
|
+
/[<>:"|?*]/,
|
|
96
|
+
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i
|
|
97
|
+
]
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Rate limiting configuration
|
|
102
|
+
*/
|
|
103
|
+
export const RATE_LIMIT_CONFIG = {
|
|
104
|
+
api: {
|
|
105
|
+
default: 100,
|
|
106
|
+
auth: 10,
|
|
107
|
+
upload: 20,
|
|
108
|
+
generation: 30,
|
|
109
|
+
export: 10
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
actions: {
|
|
113
|
+
login: 5,
|
|
114
|
+
registration: 3,
|
|
115
|
+
passwordReset: 3,
|
|
116
|
+
contentCreation: 50,
|
|
117
|
+
fileUpload: 100
|
|
118
|
+
}
|
|
119
|
+
} as const;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Session security configuration
|
|
123
|
+
*/
|
|
124
|
+
export const SESSION_CONFIG = {
|
|
125
|
+
timeout: 720,
|
|
126
|
+
refreshThreshold: 60,
|
|
127
|
+
maxSessions: 5,
|
|
128
|
+
cookie: {
|
|
129
|
+
httpOnly: true,
|
|
130
|
+
secure: true,
|
|
131
|
+
sameSite: 'strict' as const,
|
|
132
|
+
maxAge: 720 * 60 * 1000
|
|
133
|
+
}
|
|
134
|
+
} as const;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Security headers configuration
|
|
138
|
+
*/
|
|
139
|
+
export const SECURITY_HEADERS = {
|
|
140
|
+
'X-Content-Type-Options': 'nosniff',
|
|
141
|
+
'X-Frame-Options': 'DENY',
|
|
142
|
+
'X-XSS-Protection': '1; mode=block',
|
|
143
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
144
|
+
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
|
|
145
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
|
|
146
|
+
} as const;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* CORS configuration
|
|
150
|
+
*/
|
|
151
|
+
export const CORS_CONFIG = {
|
|
152
|
+
origin: [
|
|
153
|
+
'http://localhost:3000',
|
|
154
|
+
'http://localhost:5173',
|
|
155
|
+
'https://amaterasu.app'
|
|
156
|
+
],
|
|
157
|
+
credentials: true,
|
|
158
|
+
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
159
|
+
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
160
|
+
maxAge: 86400
|
|
161
|
+
} as const;
|
|
162
|
+
|
|
163
|
+
export default {
|
|
164
|
+
CSP_CONFIG,
|
|
165
|
+
VALIDATION_CONFIGS,
|
|
166
|
+
FILE_SECURITY_CONFIG,
|
|
167
|
+
RATE_LIMIT_CONFIG,
|
|
168
|
+
SESSION_CONFIG,
|
|
169
|
+
SECURITY_HEADERS,
|
|
170
|
+
CORS_CONFIG
|
|
171
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ValidationRule {
|
|
4
|
+
required?: boolean;
|
|
5
|
+
minLength?: number;
|
|
6
|
+
maxLength?: number;
|
|
7
|
+
pattern?: RegExp;
|
|
8
|
+
custom?: (value: string) => string | null;
|
|
9
|
+
message?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ValidationRules {
|
|
13
|
+
[fieldName: string]: ValidationRule;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ValidationErrors {
|
|
17
|
+
[fieldName: string]: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FormData {
|
|
21
|
+
[fieldName: string]: string | number | boolean | string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const validateField = (value: string | number | boolean | string[], rule: ValidationRule): string | null => {
|
|
25
|
+
const stringValue = Array.isArray(value) ? value.join(',') : String(value);
|
|
26
|
+
|
|
27
|
+
if (rule.required && (!stringValue || stringValue.trim() === '')) {
|
|
28
|
+
return rule.message || 'This field is required';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!stringValue || stringValue.trim() === '') {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (rule.minLength && stringValue.length < rule.minLength) {
|
|
36
|
+
return rule.message || `Must be at least ${rule.minLength} characters`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (rule.maxLength && stringValue.length > rule.maxLength) {
|
|
40
|
+
return rule.message || `Must be no more than ${rule.maxLength} characters`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (rule.pattern && !rule.pattern.test(stringValue)) {
|
|
44
|
+
return rule.message || 'Invalid format';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (rule.custom) {
|
|
48
|
+
return rule.custom(stringValue);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const sanitizeInput = (value: string): string => {
|
|
55
|
+
return value
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/[<>]/g, '')
|
|
58
|
+
.replace(/\s+/g, ' ');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const VALIDATION_PATTERNS = {
|
|
62
|
+
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
63
|
+
url: /^https?:\/\/.+/,
|
|
64
|
+
phone: /^\+?[\d\s\-()]+$/,
|
|
65
|
+
alphanumeric: /^[a-zA-Z0-9]+$/,
|
|
66
|
+
noSpecialChars: /^[a-zA-Z0-9\s]+$/,
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
export const COMMON_RULES = {
|
|
70
|
+
required: { required: true },
|
|
71
|
+
email: {
|
|
72
|
+
pattern: VALIDATION_PATTERNS.email,
|
|
73
|
+
message: 'Please enter a valid email address'
|
|
74
|
+
},
|
|
75
|
+
url: {
|
|
76
|
+
pattern: VALIDATION_PATTERNS.url,
|
|
77
|
+
message: 'Please enter a valid URL'
|
|
78
|
+
},
|
|
79
|
+
phone: {
|
|
80
|
+
pattern: VALIDATION_PATTERNS.phone,
|
|
81
|
+
message: 'Please enter a valid phone number'
|
|
82
|
+
},
|
|
83
|
+
shortText: { maxLength: 100 },
|
|
84
|
+
mediumText: { maxLength: 500 },
|
|
85
|
+
longText: { maxLength: 2000 },
|
|
86
|
+
name: {
|
|
87
|
+
minLength: 2,
|
|
88
|
+
maxLength: 50,
|
|
89
|
+
pattern: VALIDATION_PATTERNS.noSpecialChars,
|
|
90
|
+
message: 'Name must be 2-50 characters and contain only letters, numbers, and spaces'
|
|
91
|
+
},
|
|
92
|
+
description: {
|
|
93
|
+
minLength: 10,
|
|
94
|
+
maxLength: 500,
|
|
95
|
+
message: 'Description must be 10-500 characters'
|
|
96
|
+
},
|
|
97
|
+
} as const;
|
|
98
|
+
|
|
99
|
+
export const useFormValidation = <T extends FormData>(
|
|
100
|
+
initialData: T,
|
|
101
|
+
validationRules: ValidationRules = {}
|
|
102
|
+
) => {
|
|
103
|
+
const [formData, setFormData] = useState<T>(initialData);
|
|
104
|
+
const [errors, setErrors] = useState<ValidationErrors>({});
|
|
105
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
106
|
+
|
|
107
|
+
const validateSingleField = useCallback((fieldName: string, value: string | number | boolean | string[]): string | null => {
|
|
108
|
+
const rule = validationRules[fieldName];
|
|
109
|
+
if (!rule) return null;
|
|
110
|
+
|
|
111
|
+
return validateField(value, rule);
|
|
112
|
+
}, [validationRules]);
|
|
113
|
+
|
|
114
|
+
const validateAllFields = useCallback((): boolean => {
|
|
115
|
+
const newErrors: ValidationErrors = {};
|
|
116
|
+
let isValid = true;
|
|
117
|
+
|
|
118
|
+
Object.keys(validationRules).forEach(fieldName => {
|
|
119
|
+
const value = formData[fieldName];
|
|
120
|
+
const error = validateSingleField(fieldName, value);
|
|
121
|
+
|
|
122
|
+
if (error) {
|
|
123
|
+
newErrors[fieldName] = error;
|
|
124
|
+
isValid = false;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
setErrors(newErrors);
|
|
129
|
+
return isValid;
|
|
130
|
+
}, [formData, validateSingleField, validationRules]);
|
|
131
|
+
|
|
132
|
+
const updateField = useCallback((fieldName: string, value: string | number | boolean | string[]) => {
|
|
133
|
+
const sanitizedValue = typeof value === 'string' ? sanitizeInput(value) : value;
|
|
134
|
+
|
|
135
|
+
setFormData(prev => ({ ...prev, [fieldName]: sanitizedValue }));
|
|
136
|
+
|
|
137
|
+
setTouched(prev => ({ ...prev, [fieldName]: true }));
|
|
138
|
+
|
|
139
|
+
if (touched[fieldName] || value !== '') {
|
|
140
|
+
const error = validateSingleField(fieldName, sanitizedValue);
|
|
141
|
+
setErrors(prev => ({ ...prev, [fieldName]: error || '' }));
|
|
142
|
+
}
|
|
143
|
+
}, [touched, validateSingleField]);
|
|
144
|
+
|
|
145
|
+
const clearFieldError = useCallback((fieldName: string) => {
|
|
146
|
+
setErrors(prev => ({ ...prev, [fieldName]: '' }));
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
const clearAllErrors = useCallback(() => {
|
|
150
|
+
setErrors({});
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const resetForm = useCallback(() => {
|
|
154
|
+
setFormData(initialData);
|
|
155
|
+
setErrors({});
|
|
156
|
+
setTouched({});
|
|
157
|
+
}, [initialData]);
|
|
158
|
+
|
|
159
|
+
const setFormDataWithValidation = useCallback((newData: Partial<T>) => {
|
|
160
|
+
setFormData(prev => ({ ...prev, ...newData }));
|
|
161
|
+
|
|
162
|
+
const newErrors: ValidationErrors = {};
|
|
163
|
+
Object.keys(newData).forEach(fieldName => {
|
|
164
|
+
if (validationRules[fieldName]) {
|
|
165
|
+
const fieldValue = newData[fieldName];
|
|
166
|
+
const error = validateSingleField(fieldName, fieldValue as string | number | boolean | string[]);
|
|
167
|
+
if (error) {
|
|
168
|
+
newErrors[fieldName] = error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
setErrors(prev => ({ ...prev, ...newErrors }));
|
|
174
|
+
}, [validateSingleField, validationRules]);
|
|
175
|
+
|
|
176
|
+
const getFieldProps = useCallback((fieldName: string) => ({
|
|
177
|
+
value: formData[fieldName] || '',
|
|
178
|
+
onChange: (value: string | number | boolean | string[]) => updateField(fieldName, value),
|
|
179
|
+
onBlur: () => {
|
|
180
|
+
setTouched(prev => ({ ...prev, [fieldName]: true }));
|
|
181
|
+
const error = validateSingleField(fieldName, formData[fieldName]);
|
|
182
|
+
setErrors(prev => ({ ...prev, [fieldName]: error || '' }));
|
|
183
|
+
},
|
|
184
|
+
error: errors[fieldName],
|
|
185
|
+
isValid: !errors[fieldName] && touched[fieldName],
|
|
186
|
+
}), [formData, errors, touched, updateField, validateSingleField]);
|
|
187
|
+
|
|
188
|
+
const isFormValid = useMemo(() => {
|
|
189
|
+
return Object.keys(validationRules).every(fieldName => !errors[fieldName]);
|
|
190
|
+
}, [errors, validationRules]);
|
|
191
|
+
|
|
192
|
+
const hasErrors = useMemo(() => {
|
|
193
|
+
return Object.values(errors).some(error => error !== '');
|
|
194
|
+
}, [errors]);
|
|
195
|
+
|
|
196
|
+
const touchedFields = useMemo(() => {
|
|
197
|
+
return Object.keys(touched).filter(fieldName => touched[fieldName]);
|
|
198
|
+
}, [touched]);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
formData,
|
|
202
|
+
errors,
|
|
203
|
+
touched,
|
|
204
|
+
isFormValid,
|
|
205
|
+
hasErrors,
|
|
206
|
+
touchedFields,
|
|
207
|
+
updateField,
|
|
208
|
+
validateAllFields,
|
|
209
|
+
validateSingleField,
|
|
210
|
+
clearFieldError,
|
|
211
|
+
clearAllErrors,
|
|
212
|
+
resetForm,
|
|
213
|
+
setFormDataWithValidation,
|
|
214
|
+
getFieldProps,
|
|
215
|
+
};
|
|
216
|
+
};
|