@shopify/shop-minis-react 0.0.33 → 0.0.35
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/_virtual/index10.js +2 -2
- package/dist/_virtual/index2.js +4 -4
- package/dist/_virtual/index3.js +4 -4
- package/dist/_virtual/index8.js +2 -2
- package/dist/_virtual/index9.js +2 -2
- package/dist/components/atoms/alert-dialog.js.map +1 -1
- package/dist/components/atoms/icon-button.js +12 -12
- package/dist/components/atoms/icon-button.js.map +1 -1
- package/dist/components/atoms/image.js +52 -0
- package/dist/components/atoms/image.js.map +1 -0
- package/dist/components/atoms/text-input.js +22 -0
- package/dist/components/atoms/text-input.js.map +1 -0
- package/dist/components/commerce/merchant-card.js +2 -1
- package/dist/components/commerce/merchant-card.js.map +1 -1
- package/dist/components/commerce/product-card.js +11 -11
- package/dist/components/commerce/product-card.js.map +1 -1
- package/dist/components/content/image-content-wrapper.js +29 -22
- package/dist/components/content/image-content-wrapper.js.map +1 -1
- package/dist/components/ui/input.js +15 -9
- package/dist/components/ui/input.js.map +1 -1
- package/dist/hooks/content/useCreateImageContent.js +16 -22
- package/dist/hooks/content/useCreateImageContent.js.map +1 -1
- package/dist/hooks/storage/useImageUpload.js +36 -37
- package/dist/hooks/storage/useImageUpload.js.map +1 -1
- package/dist/hooks/util/useKeyboardAvoidingView.js +23 -0
- package/dist/hooks/util/useKeyboardAvoidingView.js.map +1 -0
- package/dist/index.js +218 -212
- package/dist/index.js.map +1 -1
- package/dist/mocks.js +4 -1
- package/dist/mocks.js.map +1 -1
- package/dist/shop-minis-platform/src/types/content.js.map +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/color-string@1.9.1/node_modules/color-string/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/use-sync-external-store@1.5.0_react@19.1.0/node_modules/use-sync-external-store/shim/index.js +1 -1
- package/dist/shop-minis-react/node_modules/.pnpm/video.js@8.23.3/node_modules/video.js/dist/video.es.js +1 -1
- package/dist/utils/colors.js +1 -1
- package/dist/utils/image.js +46 -9
- package/dist/utils/image.js.map +1 -1
- package/package.json +21 -4
- package/src/components/atoms/alert-dialog.test.tsx +67 -0
- package/src/components/atoms/alert-dialog.tsx +13 -11
- package/src/components/atoms/favorite-button.test.tsx +56 -0
- package/src/components/atoms/icon-button.tsx +1 -1
- package/src/components/atoms/image.test.tsx +108 -0
- package/src/components/atoms/{thumbhash-image.tsx → image.tsx} +14 -14
- package/src/components/atoms/product-variant-price.test.tsx +128 -0
- package/src/components/atoms/text-input.test.tsx +104 -0
- package/src/components/atoms/text-input.tsx +31 -0
- package/src/components/commerce/merchant-card.test.tsx +261 -0
- package/src/components/commerce/merchant-card.tsx +4 -2
- package/src/components/commerce/product-card.test.tsx +364 -0
- package/src/components/commerce/product-card.tsx +2 -2
- package/src/components/commerce/product-link.test.tsx +483 -0
- package/src/components/commerce/quantity-selector.test.tsx +382 -0
- package/src/components/commerce/search.test.tsx +487 -0
- package/src/components/content/image-content-wrapper.test.tsx +92 -0
- package/src/components/content/image-content-wrapper.tsx +9 -2
- package/src/components/index.ts +2 -1
- package/src/components/navigation/transition-link.test.tsx +155 -0
- package/src/components/ui/input.test.tsx +21 -0
- package/src/components/ui/input.tsx +10 -1
- package/src/hooks/content/useCreateImageContent.test.ts +352 -0
- package/src/hooks/content/useCreateImageContent.ts +1 -7
- package/src/hooks/index.ts +1 -0
- package/src/hooks/navigation/useNavigateWithTransition.test.ts +371 -0
- package/src/hooks/navigation/useViewTransitions.test.ts +469 -0
- package/src/hooks/product/useProductSearch.test.ts +470 -0
- package/src/hooks/storage/useAsyncStorage.test.ts +225 -0
- package/src/hooks/storage/useImageUpload.test.ts +322 -0
- package/src/hooks/storage/useImageUpload.ts +22 -20
- package/src/hooks/util/useKeyboardAvoidingView.ts +37 -0
- package/src/internal/useHandleAction.test.ts +265 -0
- package/src/internal/useShopActionsDataFetching.test.ts +465 -0
- package/src/mocks.ts +3 -1
- package/src/providers/ImagePickerProvider.test.tsx +467 -0
- package/src/stories/ProductCard.stories.tsx +2 -2
- package/src/stories/TextInput.stories.tsx +26 -0
- package/src/test-setup.ts +34 -0
- package/src/test-utils.tsx +167 -0
- package/src/utils/image.ts +73 -0
- package/src/utils/index.ts +1 -1
- package/dist/components/atoms/thumbhash-image.js +0 -54
- package/dist/components/atoms/thumbhash-image.js.map +0 -1
- package/dist/utils/imageToDataUri.js +0 -10
- package/dist/utils/imageToDataUri.js.map +0 -1
- package/src/utils/imageToDataUri.ts +0 -8
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import {renderHook, act} from '@testing-library/react'
|
|
2
|
+
import {describe, expect, it, vi, beforeEach} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {useShopActions} from '../../internal/useShopActions'
|
|
5
|
+
|
|
6
|
+
import {useImageUpload} from './useImageUpload'
|
|
7
|
+
|
|
8
|
+
// Mock dependencies
|
|
9
|
+
vi.mock('../../internal/useShopActions', () => ({
|
|
10
|
+
useShopActions: vi.fn(() => ({
|
|
11
|
+
createImageUploadLink: vi.fn(),
|
|
12
|
+
completeImageUpload: vi.fn(),
|
|
13
|
+
})),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
vi.mock('../../utils', () => ({
|
|
17
|
+
fileToDataUri: vi.fn((file: File) =>
|
|
18
|
+
Promise.resolve(`data:${file.type};base64,mockbase64data`)
|
|
19
|
+
),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// Mock fetch globally
|
|
23
|
+
global.fetch = vi.fn()
|
|
24
|
+
|
|
25
|
+
describe('useImageUpload', () => {
|
|
26
|
+
let mockCreateImageUploadLink: ReturnType<typeof vi.fn>
|
|
27
|
+
let mockCompleteImageUpload: ReturnType<typeof vi.fn>
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks()
|
|
31
|
+
|
|
32
|
+
// Reset fetch mock with proper blob() implementation
|
|
33
|
+
;(global.fetch as any).mockImplementation(async (url: string) => {
|
|
34
|
+
// For data URI fetch (fileToDataUri result)
|
|
35
|
+
if (url.startsWith('data:')) {
|
|
36
|
+
return {
|
|
37
|
+
blob: async () => new Blob(['test image data'], {type: 'image/jpeg'}),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Default for other fetches
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
text: async () => 'Upload successful',
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// Set up mock actions with proper implementations
|
|
48
|
+
mockCreateImageUploadLink = vi.fn().mockResolvedValue({
|
|
49
|
+
ok: true,
|
|
50
|
+
data: {
|
|
51
|
+
targets: [
|
|
52
|
+
{
|
|
53
|
+
url: 'https://storage.googleapis.com/upload',
|
|
54
|
+
resourceUrl: 'https://storage.googleapis.com/resource/123',
|
|
55
|
+
parameters: [
|
|
56
|
+
{name: 'key', value: 'test-key'},
|
|
57
|
+
{name: 'policy', value: 'test-policy'},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
mockCompleteImageUpload = vi.fn().mockResolvedValue({
|
|
65
|
+
ok: true,
|
|
66
|
+
data: {
|
|
67
|
+
files: [
|
|
68
|
+
{
|
|
69
|
+
id: 'uploaded-image-id',
|
|
70
|
+
fileStatus: 'READY',
|
|
71
|
+
image: {
|
|
72
|
+
url: 'https://example.com/image.jpg',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Update the mocks to return our mock actions
|
|
80
|
+
;(useShopActions as any).mockReturnValue({
|
|
81
|
+
createImageUploadLink: mockCreateImageUploadLink,
|
|
82
|
+
completeImageUpload: mockCompleteImageUpload,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('uploadImage', () => {
|
|
87
|
+
it('successfully uploads an image', async () => {
|
|
88
|
+
const {result} = renderHook(() => useImageUpload())
|
|
89
|
+
|
|
90
|
+
const testFile = new File(['test image'], 'test.jpg', {
|
|
91
|
+
type: 'image/jpeg',
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
let uploadedImages: any
|
|
95
|
+
await act(async () => {
|
|
96
|
+
uploadedImages = await result.current.uploadImage(testFile)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Verify the upload flow
|
|
100
|
+
expect(mockCreateImageUploadLink).toHaveBeenCalledWith({
|
|
101
|
+
input: [
|
|
102
|
+
{
|
|
103
|
+
mimeType: 'image/jpeg',
|
|
104
|
+
fileSize: 10, // 'test image'.length
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Verify GCS upload
|
|
110
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
111
|
+
'https://storage.googleapis.com/upload',
|
|
112
|
+
expect.objectContaining({
|
|
113
|
+
method: 'POST',
|
|
114
|
+
body: expect.any(FormData),
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
expect(mockCompleteImageUpload).toHaveBeenCalledWith({
|
|
119
|
+
resourceUrls: ['https://storage.googleapis.com/resource/123'],
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(uploadedImages).toEqual([
|
|
123
|
+
{
|
|
124
|
+
id: 'uploaded-image-id',
|
|
125
|
+
imageUrl: 'https://example.com/image.jpg',
|
|
126
|
+
resourceUrl: 'https://storage.googleapis.com/resource/123',
|
|
127
|
+
},
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('throws error when createImageUploadLink fails', async () => {
|
|
132
|
+
mockCreateImageUploadLink.mockResolvedValue({
|
|
133
|
+
ok: false,
|
|
134
|
+
error: {
|
|
135
|
+
message: 'Failed to create upload link',
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const {result} = renderHook(() => useImageUpload())
|
|
140
|
+
|
|
141
|
+
const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
act(async () => {
|
|
145
|
+
await result.current.uploadImage(testFile)
|
|
146
|
+
})
|
|
147
|
+
).rejects.toThrow('Failed to create upload link')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('throws error when GCS upload fails', async () => {
|
|
151
|
+
// Mock failed fetch for GCS upload
|
|
152
|
+
;(global.fetch as any).mockImplementation(async (url: string) => {
|
|
153
|
+
if (url.startsWith('data:')) {
|
|
154
|
+
return {
|
|
155
|
+
blob: async () => new Blob(['test'], {type: 'image/jpeg'}),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// GCS upload fails
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
text: async () => 'Upload failed',
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const {result} = renderHook(() => useImageUpload())
|
|
166
|
+
|
|
167
|
+
const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
168
|
+
|
|
169
|
+
await expect(
|
|
170
|
+
act(async () => {
|
|
171
|
+
await result.current.uploadImage(testFile)
|
|
172
|
+
})
|
|
173
|
+
).rejects.toThrow('Failed to upload image')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('throws error when completeImageUpload fails', async () => {
|
|
177
|
+
mockCompleteImageUpload.mockResolvedValue({
|
|
178
|
+
ok: false,
|
|
179
|
+
error: {
|
|
180
|
+
message: 'Failed to complete upload',
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const {result} = renderHook(() => useImageUpload())
|
|
185
|
+
|
|
186
|
+
const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
187
|
+
|
|
188
|
+
await expect(
|
|
189
|
+
act(async () => {
|
|
190
|
+
await result.current.uploadImage(testFile)
|
|
191
|
+
})
|
|
192
|
+
).rejects.toThrow('Failed to complete upload')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('polls until image is ready', async () => {
|
|
196
|
+
// First two calls return PROCESSING, third returns READY
|
|
197
|
+
mockCompleteImageUpload
|
|
198
|
+
.mockResolvedValueOnce({
|
|
199
|
+
ok: true,
|
|
200
|
+
data: {
|
|
201
|
+
files: [
|
|
202
|
+
{
|
|
203
|
+
id: 'uploaded-image-id',
|
|
204
|
+
fileStatus: 'PROCESSING',
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
.mockResolvedValueOnce({
|
|
210
|
+
ok: true,
|
|
211
|
+
data: {
|
|
212
|
+
files: [
|
|
213
|
+
{
|
|
214
|
+
id: 'uploaded-image-id',
|
|
215
|
+
fileStatus: 'PROCESSING',
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
.mockResolvedValueOnce({
|
|
221
|
+
ok: true,
|
|
222
|
+
data: {
|
|
223
|
+
files: [
|
|
224
|
+
{
|
|
225
|
+
id: 'uploaded-image-id',
|
|
226
|
+
fileStatus: 'READY',
|
|
227
|
+
image: {
|
|
228
|
+
url: 'https://example.com/processed.jpg',
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// Speed up test by mocking setTimeout
|
|
236
|
+
vi.useFakeTimers()
|
|
237
|
+
|
|
238
|
+
const {result} = renderHook(() => useImageUpload())
|
|
239
|
+
|
|
240
|
+
const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
241
|
+
|
|
242
|
+
// Start the upload without awaiting
|
|
243
|
+
let uploadPromise: Promise<any>
|
|
244
|
+
await act(async () => {
|
|
245
|
+
uploadPromise = result.current.uploadImage(testFile)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// Advance timers for polling
|
|
249
|
+
await act(async () => {
|
|
250
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const uploadedImages = await uploadPromise!
|
|
254
|
+
|
|
255
|
+
expect(mockCompleteImageUpload).toHaveBeenCalledTimes(3)
|
|
256
|
+
expect(uploadedImages).toEqual([
|
|
257
|
+
{
|
|
258
|
+
id: 'uploaded-image-id',
|
|
259
|
+
imageUrl: 'https://example.com/processed.jpg',
|
|
260
|
+
resourceUrl: 'https://storage.googleapis.com/resource/123',
|
|
261
|
+
},
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
vi.useRealTimers()
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('handles file without initial size', async () => {
|
|
268
|
+
const {result} = renderHook(() => useImageUpload())
|
|
269
|
+
|
|
270
|
+
// Create a file without size property set
|
|
271
|
+
const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
272
|
+
|
|
273
|
+
let uploadedImages: any
|
|
274
|
+
await act(async () => {
|
|
275
|
+
uploadedImages = await result.current.uploadImage(testFile)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
expect(uploadedImages).toEqual([
|
|
279
|
+
{
|
|
280
|
+
id: 'uploaded-image-id',
|
|
281
|
+
imageUrl: 'https://example.com/image.jpg',
|
|
282
|
+
resourceUrl: 'https://storage.googleapis.com/resource/123',
|
|
283
|
+
},
|
|
284
|
+
])
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('includes all form data parameters in GCS upload', async () => {
|
|
288
|
+
let capturedFormData: FormData | undefined
|
|
289
|
+
;(global.fetch as any).mockImplementation(
|
|
290
|
+
async (url: string, options: any) => {
|
|
291
|
+
if (url.startsWith('data:')) {
|
|
292
|
+
return {
|
|
293
|
+
blob: async () => new Blob(['test'], {type: 'image/jpeg'}),
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// eslint-disable-next-line jest/no-if
|
|
297
|
+
if (url === 'https://storage.googleapis.com/upload') {
|
|
298
|
+
capturedFormData = options.body
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
ok: true,
|
|
302
|
+
text: async () => 'Upload successful',
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const {result} = renderHook(() => useImageUpload())
|
|
308
|
+
|
|
309
|
+
const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
|
|
310
|
+
|
|
311
|
+
await act(async () => {
|
|
312
|
+
await result.current.uploadImage(testFile)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
// Verify FormData contains all expected fields
|
|
316
|
+
expect(capturedFormData).toBeDefined()
|
|
317
|
+
expect(capturedFormData?.get('key')).toBe('test-key')
|
|
318
|
+
expect(capturedFormData?.get('policy')).toBe('test-policy')
|
|
319
|
+
expect(capturedFormData?.get('file')).toBeDefined()
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
})
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import {useCallback} from 'react'
|
|
2
2
|
|
|
3
3
|
import {useShopActions} from '../../internal/useShopActions'
|
|
4
|
+
import {fileToDataUri} from '../../utils'
|
|
4
5
|
|
|
5
6
|
import type {UploadTarget} from '@shopify/shop-minis-platform/actions'
|
|
6
7
|
|
|
7
8
|
export interface UploadImageParams {
|
|
9
|
+
/**
|
|
10
|
+
* The file to upload.
|
|
11
|
+
*/
|
|
12
|
+
image: File
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProcessedImage {
|
|
8
16
|
/**
|
|
9
17
|
* The MIME type of the image.
|
|
10
18
|
*/
|
|
@@ -12,11 +20,11 @@ export interface UploadImageParams {
|
|
|
12
20
|
/**
|
|
13
21
|
* The size of the image in bytes.
|
|
14
22
|
*/
|
|
15
|
-
fileSize
|
|
23
|
+
fileSize: number
|
|
16
24
|
/**
|
|
17
|
-
* The
|
|
25
|
+
* The file blob of the image.
|
|
18
26
|
*/
|
|
19
|
-
|
|
27
|
+
fileBlob: Blob
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
export interface UploadedImage {
|
|
@@ -36,28 +44,27 @@ export interface UploadedImage {
|
|
|
36
44
|
|
|
37
45
|
interface UseImageUploadReturns {
|
|
38
46
|
/**
|
|
39
|
-
* Upload an image attached to the current user.
|
|
47
|
+
* Upload an image which will be attached to the current user.
|
|
40
48
|
*/
|
|
41
|
-
uploadImage: (
|
|
49
|
+
uploadImage: (image: File) => Promise<UploadedImage[]>
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
// Fetch file data and detect file sizes if not provided
|
|
45
53
|
// Works with file://, data:, and http(s):// URIs
|
|
46
|
-
const processFileData = async (image:
|
|
47
|
-
const
|
|
54
|
+
const processFileData = async (image: File): Promise<ProcessedImage> => {
|
|
55
|
+
const uri = await fileToDataUri(image)
|
|
56
|
+
|
|
57
|
+
const response = await fetch(uri)
|
|
48
58
|
const blob = await response.blob()
|
|
49
59
|
|
|
50
60
|
return {
|
|
51
|
-
|
|
52
|
-
fileSize: image.
|
|
61
|
+
mimeType: image.type,
|
|
62
|
+
fileSize: image.size ?? blob.size,
|
|
53
63
|
fileBlob: blob,
|
|
54
64
|
}
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
const uploadFileToGCS = async (
|
|
58
|
-
image: UploadImageParams & {fileSize: number; fileBlob: Blob},
|
|
59
|
-
target: UploadTarget
|
|
60
|
-
) => {
|
|
67
|
+
const uploadFileToGCS = async (image: ProcessedImage, target: UploadTarget) => {
|
|
61
68
|
const formData = new FormData()
|
|
62
69
|
target.parameters.forEach(({name, value}: {name: string; value: string}) => {
|
|
63
70
|
formData.append(name, value)
|
|
@@ -84,13 +91,8 @@ export const useImageUpload = (): UseImageUploadReturns => {
|
|
|
84
91
|
const {createImageUploadLink, completeImageUpload} = useShopActions()
|
|
85
92
|
|
|
86
93
|
const uploadImage = useCallback(
|
|
87
|
-
async (
|
|
88
|
-
|
|
89
|
-
throw new Error('Multiple image upload is not supported yet')
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const imageParams = params[0]
|
|
93
|
-
const processedImageParams = await processFileData(imageParams)
|
|
94
|
+
async (image: File) => {
|
|
95
|
+
const processedImageParams = await processFileData(image)
|
|
94
96
|
|
|
95
97
|
const links = await createImageUploadLink({
|
|
96
98
|
input: [
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {RefObject, useCallback} from 'react'
|
|
2
|
+
|
|
3
|
+
import {useShopActions} from '../../internal/useShopActions'
|
|
4
|
+
|
|
5
|
+
export interface UseKeyboardAvoidingViewReturns {
|
|
6
|
+
/**
|
|
7
|
+
* function to call when the input is focused
|
|
8
|
+
*/
|
|
9
|
+
onFocus: (ref: RefObject<HTMLElement | null>) => void
|
|
10
|
+
/**
|
|
11
|
+
* function to call when the input is blurred
|
|
12
|
+
*/
|
|
13
|
+
onBlur: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const useKeyboardAvoidingView = (): UseKeyboardAvoidingViewReturns => {
|
|
17
|
+
const {translateContentUp, translateContentDown} = useShopActions()
|
|
18
|
+
|
|
19
|
+
const onFocus = useCallback(
|
|
20
|
+
(ref: RefObject<HTMLElement | null>) => {
|
|
21
|
+
if (ref.current) {
|
|
22
|
+
const rect = ref.current.getBoundingClientRect()
|
|
23
|
+
translateContentUp({inputYPosition: rect.bottom})
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
[translateContentUp]
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const onBlur = useCallback(() => {
|
|
30
|
+
translateContentDown()
|
|
31
|
+
}, [translateContentDown])
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
onFocus,
|
|
35
|
+
onBlur,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import {renderHook} from '@testing-library/react'
|
|
2
|
+
import {describe, expect, it, vi} from 'vitest'
|
|
3
|
+
|
|
4
|
+
import {useHandleAction} from './useHandleAction'
|
|
5
|
+
|
|
6
|
+
import type {ShopActionResult} from '@shopify/shop-minis-platform/actions'
|
|
7
|
+
|
|
8
|
+
describe('useHandleAction', () => {
|
|
9
|
+
describe('Success Case', () => {
|
|
10
|
+
it('returns data when action succeeds', async () => {
|
|
11
|
+
const mockData = {id: '123', name: 'Test'}
|
|
12
|
+
const mockAction = vi.fn(() =>
|
|
13
|
+
Promise.resolve({
|
|
14
|
+
ok: true as const,
|
|
15
|
+
data: mockData,
|
|
16
|
+
} as ShopActionResult<typeof mockData>)
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
20
|
+
|
|
21
|
+
const data = await result.current()
|
|
22
|
+
|
|
23
|
+
expect(data).toEqual(mockData)
|
|
24
|
+
expect(mockAction).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('passes arguments to the action correctly', async () => {
|
|
28
|
+
const mockData = {success: true}
|
|
29
|
+
const mockAction = vi.fn((_arg1: string, _arg2: number) =>
|
|
30
|
+
Promise.resolve({
|
|
31
|
+
ok: true as const,
|
|
32
|
+
data: mockData,
|
|
33
|
+
} as ShopActionResult<typeof mockData>)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
37
|
+
|
|
38
|
+
await result.current('test', 42)
|
|
39
|
+
|
|
40
|
+
expect(mockAction).toHaveBeenCalledWith('test', 42)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('handles complex data structures', async () => {
|
|
44
|
+
const complexData = {
|
|
45
|
+
user: {
|
|
46
|
+
id: '1',
|
|
47
|
+
name: 'John',
|
|
48
|
+
addresses: [
|
|
49
|
+
{street: '123 Main St', city: 'New York'},
|
|
50
|
+
{street: '456 Oak Ave', city: 'Boston'},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
metadata: {
|
|
54
|
+
timestamp: '2024-01-01',
|
|
55
|
+
version: 2,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const mockAction = vi.fn(() =>
|
|
60
|
+
Promise.resolve({
|
|
61
|
+
ok: true as const,
|
|
62
|
+
data: complexData,
|
|
63
|
+
} as ShopActionResult<typeof complexData>)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
67
|
+
|
|
68
|
+
const data = await result.current()
|
|
69
|
+
|
|
70
|
+
expect(data).toEqual(complexData)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('handles null/undefined data', async () => {
|
|
74
|
+
const mockAction = vi.fn(() =>
|
|
75
|
+
Promise.resolve({
|
|
76
|
+
ok: true as const,
|
|
77
|
+
data: null,
|
|
78
|
+
} as ShopActionResult<null>)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
82
|
+
|
|
83
|
+
const data = await result.current()
|
|
84
|
+
|
|
85
|
+
expect(data).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('Error Case', () => {
|
|
90
|
+
it('throws error when action fails', async () => {
|
|
91
|
+
const mockError = {
|
|
92
|
+
code: 'ERROR_CODE',
|
|
93
|
+
message: 'Something went wrong',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const mockAction = vi.fn(() =>
|
|
97
|
+
Promise.resolve({
|
|
98
|
+
ok: false as const,
|
|
99
|
+
error: mockError,
|
|
100
|
+
} as ShopActionResult<any>)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
104
|
+
|
|
105
|
+
await expect(result.current()).rejects.toEqual(mockError)
|
|
106
|
+
expect(mockAction).toHaveBeenCalledTimes(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('preserves error structure', async () => {
|
|
110
|
+
const complexError = {
|
|
111
|
+
code: 'VALIDATION_ERROR',
|
|
112
|
+
message: 'Validation failed',
|
|
113
|
+
details: {
|
|
114
|
+
fields: ['email', 'password'],
|
|
115
|
+
reasons: ['Invalid format', 'Too short'],
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const mockAction = vi.fn(() =>
|
|
120
|
+
Promise.resolve({
|
|
121
|
+
ok: false as const,
|
|
122
|
+
error: complexError,
|
|
123
|
+
} as unknown as ShopActionResult<any>)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
127
|
+
|
|
128
|
+
await expect(result.current()).rejects.toEqual(complexError)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('handles string errors', async () => {
|
|
132
|
+
const mockAction = vi.fn(() =>
|
|
133
|
+
Promise.resolve({
|
|
134
|
+
ok: false as const,
|
|
135
|
+
error: 'Simple error message',
|
|
136
|
+
} as unknown as ShopActionResult<any>)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
140
|
+
|
|
141
|
+
await expect(result.current()).rejects.toBe('Simple error message')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('Function Stability', () => {
|
|
146
|
+
it('maintains reference equality across renders', () => {
|
|
147
|
+
const mockAction = vi.fn(() =>
|
|
148
|
+
Promise.resolve({
|
|
149
|
+
ok: true as const,
|
|
150
|
+
data: 'test',
|
|
151
|
+
} as ShopActionResult<string>)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const {result, rerender} = renderHook(() => useHandleAction(mockAction))
|
|
155
|
+
|
|
156
|
+
const firstRender = result.current
|
|
157
|
+
rerender()
|
|
158
|
+
const secondRender = result.current
|
|
159
|
+
|
|
160
|
+
expect(firstRender).toBe(secondRender)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('updates when action changes', () => {
|
|
164
|
+
const mockAction1 = vi.fn(() =>
|
|
165
|
+
Promise.resolve({
|
|
166
|
+
ok: true as const,
|
|
167
|
+
data: 'action1',
|
|
168
|
+
} as ShopActionResult<string>)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const mockAction2 = vi.fn(() =>
|
|
172
|
+
Promise.resolve({
|
|
173
|
+
ok: true as const,
|
|
174
|
+
data: 'action2',
|
|
175
|
+
} as ShopActionResult<string>)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const {result, rerender} = renderHook(
|
|
179
|
+
({action}) => useHandleAction(action),
|
|
180
|
+
{initialProps: {action: mockAction1}}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
const firstRender = result.current
|
|
184
|
+
|
|
185
|
+
rerender({action: mockAction2})
|
|
186
|
+
const secondRender = result.current
|
|
187
|
+
|
|
188
|
+
expect(firstRender).not.toBe(secondRender)
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('Multiple Calls', () => {
|
|
193
|
+
it('handles multiple concurrent calls', async () => {
|
|
194
|
+
let callCount = 0
|
|
195
|
+
const mockAction = vi.fn(async () => {
|
|
196
|
+
const currentCall = ++callCount
|
|
197
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
198
|
+
return {
|
|
199
|
+
ok: true as const,
|
|
200
|
+
data: currentCall,
|
|
201
|
+
} as ShopActionResult<number>
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
205
|
+
|
|
206
|
+
const [result1, result2, result3] = await Promise.all([
|
|
207
|
+
result.current(),
|
|
208
|
+
result.current(),
|
|
209
|
+
result.current(),
|
|
210
|
+
])
|
|
211
|
+
|
|
212
|
+
expect(result1).toBe(1)
|
|
213
|
+
expect(result2).toBe(2)
|
|
214
|
+
expect(result3).toBe(3)
|
|
215
|
+
expect(mockAction).toHaveBeenCalledTimes(3)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('handles sequential calls', async () => {
|
|
219
|
+
let counter = 0
|
|
220
|
+
const mockAction = vi.fn(
|
|
221
|
+
async () =>
|
|
222
|
+
({
|
|
223
|
+
ok: true as const,
|
|
224
|
+
data: ++counter,
|
|
225
|
+
}) as ShopActionResult<number>
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
229
|
+
|
|
230
|
+
const result1 = await result.current()
|
|
231
|
+
const result2 = await result.current()
|
|
232
|
+
const result3 = await result.current()
|
|
233
|
+
|
|
234
|
+
expect(result1).toBe(1)
|
|
235
|
+
expect(result2).toBe(2)
|
|
236
|
+
expect(result3).toBe(3)
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
describe('Promise Behavior', () => {
|
|
241
|
+
it('returns a promise', () => {
|
|
242
|
+
const mockAction = vi.fn(() =>
|
|
243
|
+
Promise.resolve({
|
|
244
|
+
ok: true as const,
|
|
245
|
+
data: 'test',
|
|
246
|
+
} as ShopActionResult<string>)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
250
|
+
|
|
251
|
+
const returnValue = result.current()
|
|
252
|
+
|
|
253
|
+
expect(returnValue).toBeInstanceOf(Promise)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('handles rejected promises from action', async () => {
|
|
257
|
+
const networkError = new Error('Network error')
|
|
258
|
+
const mockAction = vi.fn(() => Promise.reject(networkError))
|
|
259
|
+
|
|
260
|
+
const {result} = renderHook(() => useHandleAction(mockAction))
|
|
261
|
+
|
|
262
|
+
await expect(result.current()).rejects.toThrow('Network error')
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
})
|