@moontra/moonui-pro 2.0.21 → 2.0.23
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/dist/index.mjs +771 -20
- package/package.json +2 -1
- package/src/__tests__/use-intersection-observer.test.tsx +216 -0
- package/src/__tests__/use-local-storage.test.tsx +174 -0
- package/src/__tests__/use-pro-access.test.tsx +183 -0
- package/src/components/advanced-chart/advanced-chart.test.tsx +281 -0
- package/src/components/advanced-chart/index.tsx +412 -0
- package/src/components/advanced-forms/index.tsx +431 -0
- package/src/components/animated-button/index.tsx +202 -0
- package/src/components/calendar/event-dialog.tsx +372 -0
- package/src/components/calendar/index.tsx +531 -0
- package/src/components/color-picker/index.tsx +434 -0
- package/src/components/dashboard/index.tsx +334 -0
- package/src/components/data-table/data-table.test.tsx +187 -0
- package/src/components/data-table/index.tsx +368 -0
- package/src/components/draggable-list/index.tsx +100 -0
- package/src/components/enhanced/button.tsx +360 -0
- package/src/components/enhanced/card.tsx +272 -0
- package/src/components/enhanced/dialog.tsx +248 -0
- package/src/components/enhanced/index.ts +3 -0
- package/src/components/error-boundary/index.tsx +111 -0
- package/src/components/file-upload/file-upload.test.tsx +242 -0
- package/src/components/file-upload/index.tsx +362 -0
- package/src/components/floating-action-button/index.tsx +209 -0
- package/src/components/github-stars/index.tsx +414 -0
- package/src/components/health-check/index.tsx +441 -0
- package/src/components/hover-card-3d/index.tsx +170 -0
- package/src/components/index.ts +76 -0
- package/src/components/kanban/index.tsx +436 -0
- package/src/components/lazy-component/index.tsx +342 -0
- package/src/components/magnetic-button/index.tsx +170 -0
- package/src/components/memory-efficient-data/index.tsx +352 -0
- package/src/components/optimized-image/index.tsx +427 -0
- package/src/components/performance-debugger/index.tsx +591 -0
- package/src/components/performance-monitor/index.tsx +775 -0
- package/src/components/pinch-zoom/index.tsx +172 -0
- package/src/components/rich-text-editor/index-old-backup.tsx +443 -0
- package/src/components/rich-text-editor/index.tsx +1537 -0
- package/src/components/rich-text-editor/slash-commands-extension.ts +220 -0
- package/src/components/rich-text-editor/slash-commands.css +35 -0
- package/src/components/rich-text-editor/table-styles.css +65 -0
- package/src/components/spotlight-card/index.tsx +194 -0
- package/src/components/swipeable-card/index.tsx +100 -0
- package/src/components/timeline/index.tsx +333 -0
- package/src/components/ui/animated-button.tsx +185 -0
- package/src/components/ui/avatar.tsx +135 -0
- package/src/components/ui/badge.tsx +225 -0
- package/src/components/ui/button.tsx +221 -0
- package/src/components/ui/card.tsx +141 -0
- package/src/components/ui/checkbox.tsx +256 -0
- package/src/components/ui/color-picker.tsx +95 -0
- package/src/components/ui/dialog.tsx +332 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/hover-card-3d.tsx +103 -0
- package/src/components/ui/index.ts +33 -0
- package/src/components/ui/input.tsx +219 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/magnetic-button.tsx +129 -0
- package/src/components/ui/popover.tsx +183 -0
- package/src/components/ui/select.tsx +273 -0
- package/src/components/ui/separator.tsx +140 -0
- package/src/components/ui/slider.tsx +351 -0
- package/src/components/ui/spotlight-card.tsx +119 -0
- package/src/components/ui/switch.tsx +83 -0
- package/src/components/ui/tabs.tsx +195 -0
- package/src/components/ui/textarea.tsx +25 -0
- package/src/components/ui/toast.tsx +313 -0
- package/src/components/ui/tooltip.tsx +152 -0
- package/src/components/virtual-list/index.tsx +369 -0
- package/src/hooks/use-chart.ts +205 -0
- package/src/hooks/use-data-table.ts +182 -0
- package/src/hooks/use-docs-pro-access.ts +13 -0
- package/src/hooks/use-license-check.ts +65 -0
- package/src/hooks/use-subscription.ts +19 -0
- package/src/index.ts +11 -0
- package/src/lib/micro-interactions.ts +255 -0
- package/src/lib/utils.ts +6 -0
- package/src/patterns/login-form/index.tsx +276 -0
- package/src/patterns/login-form/types.ts +67 -0
- package/src/setupTests.ts +41 -0
- package/src/styles/design-system.css +365 -0
- package/src/styles/index.css +4 -0
- package/src/styles/tailwind.css +6 -0
- package/src/styles/tokens.css +453 -0
- package/src/types/moonui.d.ts +22 -0
- package/src/use-intersection-observer.tsx +154 -0
- package/src/use-local-storage.tsx +71 -0
- package/src/use-paddle.ts +138 -0
- package/src/use-performance-optimizer.ts +379 -0
- package/src/use-pro-access.ts +141 -0
- package/src/use-scroll-animation.ts +221 -0
- package/src/use-subscription.ts +37 -0
- package/src/use-toast.ts +32 -0
- package/src/utils/chart-helpers.ts +257 -0
- package/src/utils/cn.ts +69 -0
- package/src/utils/data-processing.ts +151 -0
- package/src/utils/license-validator.tsx +183 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { cn } from '../../utils/cn'
|
|
3
|
+
import { useLicenseCheck } from '../../hooks/use-license-check'
|
|
4
|
+
import { LicenseError } from '../../components/license-error'
|
|
5
|
+
import { Skeleton } from '../../components/skeleton'
|
|
6
|
+
import type { LoginFormProps, LoginData } from './types'
|
|
7
|
+
|
|
8
|
+
export function LoginFormCore({
|
|
9
|
+
theme = {},
|
|
10
|
+
className,
|
|
11
|
+
layout = 'single',
|
|
12
|
+
logo,
|
|
13
|
+
header,
|
|
14
|
+
footer,
|
|
15
|
+
features = {},
|
|
16
|
+
onSubmit,
|
|
17
|
+
onSocialLogin,
|
|
18
|
+
onForgotPassword,
|
|
19
|
+
validation,
|
|
20
|
+
texts = {},
|
|
21
|
+
autoFocus = true,
|
|
22
|
+
disabled = false,
|
|
23
|
+
}: LoginFormProps) {
|
|
24
|
+
const { isValid, isLoading } = useLicenseCheck()
|
|
25
|
+
const [formData, setFormData] = useState<LoginData>({
|
|
26
|
+
email: '',
|
|
27
|
+
password: '',
|
|
28
|
+
rememberMe: false,
|
|
29
|
+
})
|
|
30
|
+
const [errors, setErrors] = useState<Partial<LoginData>>({})
|
|
31
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
32
|
+
|
|
33
|
+
// License kontrolü
|
|
34
|
+
if (isLoading) {
|
|
35
|
+
return <Skeleton className="w-full h-96" />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!isValid) {
|
|
39
|
+
return <LicenseError className={className} />
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Form submit handler
|
|
43
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
|
|
46
|
+
if (disabled || isSubmitting) return
|
|
47
|
+
|
|
48
|
+
// Validation
|
|
49
|
+
const newErrors: Partial<LoginData> = {}
|
|
50
|
+
|
|
51
|
+
if (validation?.email) {
|
|
52
|
+
const emailError = validation.email(formData.email)
|
|
53
|
+
if (emailError) newErrors.email = emailError
|
|
54
|
+
} else if (!formData.email) {
|
|
55
|
+
newErrors.email = 'Email is required'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (validation?.password) {
|
|
59
|
+
const passwordError = validation.password(formData.password)
|
|
60
|
+
if (passwordError) newErrors.password = passwordError
|
|
61
|
+
} else if (!formData.password) {
|
|
62
|
+
newErrors.password = 'Password is required'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (Object.keys(newErrors).length > 0) {
|
|
66
|
+
setErrors(newErrors)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setIsSubmitting(true)
|
|
71
|
+
try {
|
|
72
|
+
await onSubmit?.(formData)
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Login error:', error)
|
|
75
|
+
} finally {
|
|
76
|
+
setIsSubmitting(false)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Social login handler
|
|
81
|
+
const handleSocialLogin = async (provider: string) => {
|
|
82
|
+
if (disabled || isSubmitting) return
|
|
83
|
+
|
|
84
|
+
setIsSubmitting(true)
|
|
85
|
+
try {
|
|
86
|
+
await onSocialLogin?.(provider)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error('Social login error:', error)
|
|
89
|
+
} finally {
|
|
90
|
+
setIsSubmitting(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Layout classes
|
|
95
|
+
const layoutClasses = {
|
|
96
|
+
single: 'max-w-md mx-auto',
|
|
97
|
+
'two-column': 'grid md:grid-cols-2 gap-8',
|
|
98
|
+
centered: 'max-w-md mx-auto text-center',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Theme styles
|
|
102
|
+
const themeStyles = {
|
|
103
|
+
'--login-primary': theme.primary || '#6366f1',
|
|
104
|
+
'--login-secondary': theme.secondary || '#8b5cf6',
|
|
105
|
+
'--login-background': theme.background || '#ffffff',
|
|
106
|
+
'--login-text': theme.text || '#1f2937',
|
|
107
|
+
'--login-border-radius': theme.borderRadius || '0.5rem',
|
|
108
|
+
'--login-spacing': theme.spacing === 'compact' ? '0.5rem' : theme.spacing === 'relaxed' ? '1.5rem' : '1rem',
|
|
109
|
+
} as React.CSSProperties
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
className={cn(
|
|
114
|
+
'w-full',
|
|
115
|
+
layoutClasses[layout],
|
|
116
|
+
className
|
|
117
|
+
)}
|
|
118
|
+
style={themeStyles}
|
|
119
|
+
>
|
|
120
|
+
<div className="space-y-6 p-8 rounded-lg border bg-[var(--login-background)] shadow-sm">
|
|
121
|
+
{/* Logo & Header */}
|
|
122
|
+
{(logo || header) && (
|
|
123
|
+
<div className="space-y-4">
|
|
124
|
+
{logo && <div className="flex justify-center">{logo}</div>}
|
|
125
|
+
{header}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Title */}
|
|
130
|
+
<div className="space-y-2 text-center">
|
|
131
|
+
<h2 className="text-2xl font-bold text-[var(--login-text)]">
|
|
132
|
+
{texts.title || 'Welcome back'}
|
|
133
|
+
</h2>
|
|
134
|
+
{texts.subtitle && (
|
|
135
|
+
<p className="text-sm text-gray-600">{texts.subtitle}</p>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Form */}
|
|
140
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
141
|
+
{/* Email field */}
|
|
142
|
+
<div className="space-y-2">
|
|
143
|
+
<label className="text-sm font-medium text-[var(--login-text)]">
|
|
144
|
+
{texts.emailLabel || 'Email'}
|
|
145
|
+
</label>
|
|
146
|
+
<input
|
|
147
|
+
type="email"
|
|
148
|
+
value={formData.email}
|
|
149
|
+
onChange={(e) => {
|
|
150
|
+
setFormData({ ...formData, email: e.target.value })
|
|
151
|
+
setErrors({ ...errors, email: undefined })
|
|
152
|
+
}}
|
|
153
|
+
className={cn(
|
|
154
|
+
"w-full px-3 py-2 border rounded-[var(--login-border-radius)] focus:outline-none focus:ring-2 focus:ring-[var(--login-primary)]",
|
|
155
|
+
errors.email && "border-red-500"
|
|
156
|
+
)}
|
|
157
|
+
placeholder="name@example.com"
|
|
158
|
+
autoFocus={autoFocus}
|
|
159
|
+
disabled={disabled || isSubmitting}
|
|
160
|
+
/>
|
|
161
|
+
{errors.email && (
|
|
162
|
+
<p className="text-sm text-red-500">{errors.email}</p>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{/* Password field */}
|
|
167
|
+
<div className="space-y-2">
|
|
168
|
+
<label className="text-sm font-medium text-[var(--login-text)]">
|
|
169
|
+
{texts.passwordLabel || 'Password'}
|
|
170
|
+
</label>
|
|
171
|
+
<input
|
|
172
|
+
type="password"
|
|
173
|
+
value={formData.password}
|
|
174
|
+
onChange={(e) => {
|
|
175
|
+
setFormData({ ...formData, password: e.target.value })
|
|
176
|
+
setErrors({ ...errors, password: undefined })
|
|
177
|
+
}}
|
|
178
|
+
className={cn(
|
|
179
|
+
"w-full px-3 py-2 border rounded-[var(--login-border-radius)] focus:outline-none focus:ring-2 focus:ring-[var(--login-primary)]",
|
|
180
|
+
errors.password && "border-red-500"
|
|
181
|
+
)}
|
|
182
|
+
placeholder="••••••••"
|
|
183
|
+
disabled={disabled || isSubmitting}
|
|
184
|
+
/>
|
|
185
|
+
{errors.password && (
|
|
186
|
+
<p className="text-sm text-red-500">{errors.password}</p>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{/* Remember me & Forgot password */}
|
|
191
|
+
{(features.rememberMe || features.forgotPassword) && (
|
|
192
|
+
<div className="flex items-center justify-between">
|
|
193
|
+
{features.rememberMe && (
|
|
194
|
+
<label className="flex items-center">
|
|
195
|
+
<input
|
|
196
|
+
type="checkbox"
|
|
197
|
+
checked={formData.rememberMe}
|
|
198
|
+
onChange={(e) => setFormData({ ...formData, rememberMe: e.target.checked })}
|
|
199
|
+
className="mr-2"
|
|
200
|
+
disabled={disabled || isSubmitting}
|
|
201
|
+
/>
|
|
202
|
+
<span className="text-sm text-gray-600">
|
|
203
|
+
{texts.rememberMeLabel || 'Remember me'}
|
|
204
|
+
</span>
|
|
205
|
+
</label>
|
|
206
|
+
)}
|
|
207
|
+
{features.forgotPassword && (
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={onForgotPassword}
|
|
211
|
+
className="text-sm text-[var(--login-primary)] hover:underline"
|
|
212
|
+
disabled={disabled || isSubmitting}
|
|
213
|
+
>
|
|
214
|
+
{texts.forgotPasswordLink || 'Forgot password?'}
|
|
215
|
+
</button>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{/* Submit button */}
|
|
221
|
+
<button
|
|
222
|
+
type="submit"
|
|
223
|
+
disabled={disabled || isSubmitting}
|
|
224
|
+
className={cn(
|
|
225
|
+
"w-full py-2 px-4 bg-[var(--login-primary)] text-white rounded-[var(--login-border-radius)] font-medium",
|
|
226
|
+
"hover:opacity-90 transition-opacity",
|
|
227
|
+
"disabled:opacity-50 disabled:cursor-not-allowed"
|
|
228
|
+
)}
|
|
229
|
+
>
|
|
230
|
+
{isSubmitting ? 'Loading...' : (texts.submitButton || 'Sign in')}
|
|
231
|
+
</button>
|
|
232
|
+
</form>
|
|
233
|
+
|
|
234
|
+
{/* Social login */}
|
|
235
|
+
{features.socialLogin && (
|
|
236
|
+
<>
|
|
237
|
+
<div className="relative">
|
|
238
|
+
<div className="absolute inset-0 flex items-center">
|
|
239
|
+
<div className="w-full border-t"></div>
|
|
240
|
+
</div>
|
|
241
|
+
<div className="relative flex justify-center text-sm">
|
|
242
|
+
<span className="px-2 bg-[var(--login-background)] text-gray-500">
|
|
243
|
+
{texts.orContinueWith || 'Or continue with'}
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div className="grid grid-cols-2 gap-3">
|
|
249
|
+
{(Array.isArray(features.socialLogin) ? features.socialLogin : ['google', 'github']).map((provider) => (
|
|
250
|
+
<button
|
|
251
|
+
key={provider}
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => handleSocialLogin(provider)}
|
|
254
|
+
disabled={disabled || isSubmitting}
|
|
255
|
+
className={cn(
|
|
256
|
+
"flex items-center justify-center py-2 px-4 border rounded-[var(--login-border-radius)]",
|
|
257
|
+
"hover:bg-gray-50 transition-colors",
|
|
258
|
+
"disabled:opacity-50 disabled:cursor-not-allowed"
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
{provider.charAt(0).toUpperCase() + provider.slice(1)}
|
|
262
|
+
</button>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{/* Footer */}
|
|
269
|
+
{footer}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Export types
|
|
276
|
+
export type { LoginFormProps, LoginData, LoginFormTheme, LoginFormFeatures, LoginFormTexts } from './types'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface LoginFormTheme {
|
|
2
|
+
primary?: string
|
|
3
|
+
secondary?: string
|
|
4
|
+
background?: string
|
|
5
|
+
text?: string
|
|
6
|
+
borderRadius?: string
|
|
7
|
+
spacing?: 'compact' | 'normal' | 'relaxed'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LoginFormFeatures {
|
|
11
|
+
socialLogin?: boolean | string[]
|
|
12
|
+
rememberMe?: boolean
|
|
13
|
+
forgotPassword?: boolean
|
|
14
|
+
twoFactor?: boolean
|
|
15
|
+
passwordStrength?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LoginFormTexts {
|
|
19
|
+
title?: string
|
|
20
|
+
subtitle?: string
|
|
21
|
+
emailLabel?: string
|
|
22
|
+
passwordLabel?: string
|
|
23
|
+
submitButton?: string
|
|
24
|
+
forgotPasswordLink?: string
|
|
25
|
+
signUpLink?: string
|
|
26
|
+
rememberMeLabel?: string
|
|
27
|
+
orContinueWith?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoginData {
|
|
31
|
+
email: string
|
|
32
|
+
password: string
|
|
33
|
+
rememberMe?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ValidationRules {
|
|
37
|
+
email?: (value: string) => string | null
|
|
38
|
+
password?: (value: string) => string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LoginFormProps {
|
|
42
|
+
// Stil özelleştirme
|
|
43
|
+
theme?: LoginFormTheme
|
|
44
|
+
className?: string
|
|
45
|
+
|
|
46
|
+
// Yapı özelleştirme
|
|
47
|
+
layout?: 'single' | 'two-column' | 'centered'
|
|
48
|
+
logo?: React.ReactNode
|
|
49
|
+
header?: React.ReactNode
|
|
50
|
+
footer?: React.ReactNode
|
|
51
|
+
|
|
52
|
+
// Özellik kontrolü
|
|
53
|
+
features?: LoginFormFeatures
|
|
54
|
+
|
|
55
|
+
// Davranış özelleştirme
|
|
56
|
+
onSubmit?: (data: LoginData) => Promise<void> | void
|
|
57
|
+
onSocialLogin?: (provider: string) => Promise<void> | void
|
|
58
|
+
onForgotPassword?: () => void
|
|
59
|
+
validation?: ValidationRules
|
|
60
|
+
|
|
61
|
+
// İçerik özelleştirme
|
|
62
|
+
texts?: LoginFormTexts
|
|
63
|
+
|
|
64
|
+
// Diğer
|
|
65
|
+
autoFocus?: boolean
|
|
66
|
+
disabled?: boolean
|
|
67
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import '@testing-library/jest-dom'
|
|
2
|
+
|
|
3
|
+
// Mock ResizeObserver
|
|
4
|
+
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
5
|
+
observe: jest.fn(),
|
|
6
|
+
unobserve: jest.fn(),
|
|
7
|
+
disconnect: jest.fn(),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
// Mock IntersectionObserver
|
|
11
|
+
global.IntersectionObserver = jest.fn().mockImplementation(() => ({
|
|
12
|
+
observe: jest.fn(),
|
|
13
|
+
unobserve: jest.fn(),
|
|
14
|
+
disconnect: jest.fn(),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
// Mock matchMedia
|
|
18
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
19
|
+
writable: true,
|
|
20
|
+
value: jest.fn().mockImplementation(query => ({
|
|
21
|
+
matches: false,
|
|
22
|
+
media: query,
|
|
23
|
+
onchange: null,
|
|
24
|
+
addListener: jest.fn(),
|
|
25
|
+
removeListener: jest.fn(),
|
|
26
|
+
addEventListener: jest.fn(),
|
|
27
|
+
removeEventListener: jest.fn(),
|
|
28
|
+
dispatchEvent: jest.fn(),
|
|
29
|
+
})),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Mock console methods to reduce noise in tests
|
|
33
|
+
global.console = {
|
|
34
|
+
...console,
|
|
35
|
+
warn: jest.fn(),
|
|
36
|
+
error: jest.fn(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Mock URL.createObjectURL
|
|
40
|
+
URL.createObjectURL = jest.fn(() => 'mocked-url')
|
|
41
|
+
URL.revokeObjectURL = jest.fn()
|