@shopify/shop-minis-react 0.1.1 → 0.1.3

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/src/mocks.ts CHANGED
@@ -1,4 +1,9 @@
1
- import {Product, Gender, UserState} from '@shopify/shop-minis-platform'
1
+ import {
2
+ Product,
3
+ Gender,
4
+ UserState,
5
+ MinisContentStatus,
6
+ } from '@shopify/shop-minis-platform'
2
7
  import {ShopActions} from '@shopify/shop-minis-platform/actions'
3
8
 
4
9
  // Helper functions for common data structures
@@ -440,6 +445,7 @@ export function makeMockActions(): ShopActions {
440
445
  },
441
446
  title: 'Mock Content',
442
447
  visibility: ['DISCOVERABLE'],
448
+ status: MinisContentStatus.READY,
443
449
  },
444
450
  ],
445
451
  },
@@ -451,6 +457,9 @@ export function makeMockActions(): ShopActions {
451
457
  },
452
458
  },
453
459
  navigateToCart: undefined,
460
+ requestPermission: {
461
+ granted: true,
462
+ },
454
463
  } as const
455
464
 
456
465
  const mock: Partial<ShopActions> = {}
@@ -5,13 +5,30 @@ import {
5
5
  renderHook,
6
6
  act,
7
7
  } from '@testing-library/react'
8
- import {describe, expect, it, vi, beforeEach} from 'vitest'
8
+ import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'
9
9
 
10
10
  import {ImagePickerProvider, useImagePickerContext} from './ImagePickerProvider'
11
11
 
12
+ // Mock useRequestPermissions hook
13
+ const mockRequestPermission = vi.fn()
14
+ vi.mock('../hooks/util/useRequestPermissions', () => ({
15
+ useRequestPermissions: () => ({
16
+ requestPermission: mockRequestPermission,
17
+ }),
18
+ }))
19
+
12
20
  describe('ImagePickerProvider', () => {
21
+ const originalMinisParams = window.minisParams
22
+
13
23
  beforeEach(() => {
14
24
  vi.clearAllMocks()
25
+ // Default to granting permission
26
+ mockRequestPermission.mockResolvedValue({granted: true})
27
+ })
28
+
29
+ afterEach(() => {
30
+ // Restore original minisParams
31
+ window.minisParams = originalMinisParams
15
32
  })
16
33
 
17
34
  describe('Context', () => {
@@ -30,7 +47,7 @@ describe('ImagePickerProvider', () => {
30
47
 
31
48
  it('provides context value when used within provider', () => {
32
49
  const {result} = renderHook(() => useImagePickerContext(), {
33
- wrapper: ({children}) => (
50
+ wrapper: ({children}: {children: React.ReactNode}) => (
34
51
  <ImagePickerProvider>{children}</ImagePickerProvider>
35
52
  ),
36
53
  })
@@ -72,7 +89,7 @@ describe('ImagePickerProvider', () => {
72
89
  })
73
90
 
74
91
  describe('openGallery', () => {
75
- it('triggers gallery input click and resolves with selected file', async () => {
92
+ it('triggers gallery input click directly on non-Android platforms', async () => {
76
93
  const TestComponent = () => {
77
94
  const {openGallery} = useImagePickerContext()
78
95
 
@@ -181,10 +198,106 @@ describe('ImagePickerProvider', () => {
181
198
  expect(cancelMessage?.textContent).toBe('User cancelled file selection')
182
199
  })
183
200
  })
201
+
202
+ it('requests CAMERA permission on Android before showing picker', async () => {
203
+ window.minisParams = {...window.minisParams, platform: 'android'} as any
204
+ mockRequestPermission.mockResolvedValue({granted: true})
205
+
206
+ const TestComponent = () => {
207
+ const {openGallery} = useImagePickerContext()
208
+
209
+ return (
210
+ <button
211
+ type="button"
212
+ onClick={() =>
213
+ openGallery().catch(() => {
214
+ // Ignore errors from cleanup
215
+ })
216
+ }
217
+ >
218
+ Open Gallery
219
+ </button>
220
+ )
221
+ }
222
+
223
+ const {container} = render(
224
+ <ImagePickerProvider>
225
+ <TestComponent />
226
+ </ImagePickerProvider>
227
+ )
228
+
229
+ const galleryInput = container.querySelector(
230
+ 'input[type="file"]:not([capture])'
231
+ ) as HTMLInputElement
232
+ const clickSpy = vi.spyOn(galleryInput, 'click')
233
+
234
+ const button = screen.getByText('Open Gallery')
235
+ fireEvent.click(button)
236
+
237
+ // Wait for permission request to complete
238
+ await vi.waitFor(() => {
239
+ expect(mockRequestPermission).toHaveBeenCalledWith({
240
+ permission: 'CAMERA',
241
+ })
242
+ })
243
+
244
+ // Input should be clicked after permission request
245
+ await vi.waitFor(() => {
246
+ expect(clickSpy).toHaveBeenCalledTimes(1)
247
+ })
248
+ })
249
+
250
+ it('still opens picker on Android even if CAMERA permission is denied', async () => {
251
+ window.minisParams = {...window.minisParams, platform: 'android'} as any
252
+ mockRequestPermission.mockRejectedValue(new Error('Permission denied'))
253
+
254
+ const TestComponent = () => {
255
+ const {openGallery} = useImagePickerContext()
256
+
257
+ return (
258
+ <button
259
+ type="button"
260
+ onClick={() =>
261
+ openGallery().catch(() => {
262
+ // Ignore errors from cleanup
263
+ })
264
+ }
265
+ >
266
+ Open Gallery
267
+ </button>
268
+ )
269
+ }
270
+
271
+ const {container} = render(
272
+ <ImagePickerProvider>
273
+ <TestComponent />
274
+ </ImagePickerProvider>
275
+ )
276
+
277
+ const galleryInput = container.querySelector(
278
+ 'input[type="file"]:not([capture])'
279
+ ) as HTMLInputElement
280
+ const clickSpy = vi.spyOn(galleryInput, 'click')
281
+
282
+ const button = screen.getByText('Open Gallery')
283
+ fireEvent.click(button)
284
+
285
+ // Wait for permission request to complete
286
+ await vi.waitFor(() => {
287
+ expect(mockRequestPermission).toHaveBeenCalledWith({
288
+ permission: 'CAMERA',
289
+ })
290
+ })
291
+
292
+ // Input should still be clicked even after permission rejection
293
+ await vi.waitFor(() => {
294
+ expect(clickSpy).toHaveBeenCalledTimes(1)
295
+ })
296
+ })
184
297
  })
185
298
 
186
299
  describe('openCamera', () => {
187
- it('triggers back camera input click by default', async () => {
300
+ it('triggers back camera input click directly on non-Android platforms', async () => {
188
301
  const TestComponent = () => {
189
302
  const {openCamera} = useImagePickerContext()
190
303
 
@@ -356,6 +469,218 @@ describe('ImagePickerProvider', () => {
356
469
  expect(cancelMessage?.textContent).toBe('User cancelled camera')
357
470
  })
358
471
  })
472
+
473
+ it('requests CAMERA permission on Android and opens camera if granted', async () => {
474
+ window.minisParams = {...window.minisParams, platform: 'android'} as any
475
+ mockRequestPermission.mockResolvedValue({granted: true})
476
+
477
+ const TestComponent = () => {
478
+ const {openCamera} = useImagePickerContext()
479
+
480
+ return (
481
+ <button
482
+ type="button"
483
+ onClick={() =>
484
+ openCamera().catch(() => {
485
+ // Ignore errors from cleanup
486
+ })
487
+ }
488
+ >
489
+ Open Camera
490
+ </button>
491
+ )
492
+ }
493
+
494
+ const {container} = render(
495
+ <ImagePickerProvider>
496
+ <TestComponent />
497
+ </ImagePickerProvider>
498
+ )
499
+
500
+ const cameraInput = container.querySelector(
501
+ 'input[capture="environment"]'
502
+ ) as HTMLInputElement
503
+ const clickSpy = vi.spyOn(cameraInput, 'click')
504
+
505
+ const button = screen.getByText('Open Camera')
506
+ fireEvent.click(button)
507
+
508
+ // Wait for permission request to complete
509
+ await vi.waitFor(() => {
510
+ expect(mockRequestPermission).toHaveBeenCalledWith({
511
+ permission: 'CAMERA',
512
+ })
513
+ })
514
+
515
+ // Input should be clicked after permission is granted
516
+ await vi.waitFor(() => {
517
+ expect(clickSpy).toHaveBeenCalledTimes(1)
518
+ })
519
+ })
520
+
521
+ it('rejects with error on Android if CAMERA permission is not granted', async () => {
522
+ window.minisParams = {...window.minisParams, platform: 'android'} as any
523
+ mockRequestPermission.mockResolvedValue({granted: false})
524
+
525
+ const TestComponent = () => {
526
+ const {openCamera} = useImagePickerContext()
527
+
528
+ return (
529
+ <button
530
+ type="button"
531
+ onClick={() =>
532
+ openCamera().catch(error => {
533
+ const span = document.createElement('span')
534
+ span.textContent = error.message
535
+ span.setAttribute('data-testid', 'permission-error')
536
+ document.body.appendChild(span)
537
+ })
538
+ }
539
+ >
540
+ Open Camera
541
+ </button>
542
+ )
543
+ }
544
+
545
+ const {container} = render(
546
+ <ImagePickerProvider>
547
+ <TestComponent />
548
+ </ImagePickerProvider>
549
+ )
550
+
551
+ const cameraInput = container.querySelector(
552
+ 'input[capture="environment"]'
553
+ ) as HTMLInputElement
554
+ const clickSpy = vi.spyOn(cameraInput, 'click')
555
+
556
+ const button = screen.getByText('Open Camera')
557
+ fireEvent.click(button)
558
+
559
+ // Wait for permission request to complete
560
+ await vi.waitFor(() => {
561
+ expect(mockRequestPermission).toHaveBeenCalledWith({
562
+ permission: 'CAMERA',
563
+ })
564
+ })
565
+
566
+ // Input should NOT be clicked when permission is denied
567
+ expect(clickSpy).not.toHaveBeenCalled()
568
+
569
+ // Should show error message
570
+ await vi.waitFor(() => {
571
+ const errorMessage = document.querySelector(
572
+ '[data-testid="permission-error"]'
573
+ )
574
+ expect(errorMessage?.textContent).toBe('Camera permission not granted')
575
+ })
576
+ })
577
+
578
+ it('rejects with error on Android if permission request fails', async () => {
579
+ window.minisParams = {...window.minisParams, platform: 'android'} as any
580
+ mockRequestPermission.mockRejectedValue(
581
+ new Error('Permission request failed')
582
+ )
583
+
584
+ const TestComponent = () => {
585
+ const {openCamera} = useImagePickerContext()
586
+
587
+ return (
588
+ <button
589
+ type="button"
590
+ onClick={() =>
591
+ openCamera().catch(error => {
592
+ const span = document.createElement('span')
593
+ span.textContent = error.message
594
+ span.setAttribute('data-testid', 'permission-error')
595
+ document.body.appendChild(span)
596
+ })
597
+ }
598
+ >
599
+ Open Camera
600
+ </button>
601
+ )
602
+ }
603
+
604
+ const {container} = render(
605
+ <ImagePickerProvider>
606
+ <TestComponent />
607
+ </ImagePickerProvider>
608
+ )
609
+
610
+ const cameraInput = container.querySelector(
611
+ 'input[capture="environment"]'
612
+ ) as HTMLInputElement
613
+ const clickSpy = vi.spyOn(cameraInput, 'click')
614
+
615
+ const button = screen.getByText('Open Camera')
616
+ fireEvent.click(button)
617
+
618
+ // Wait for permission request to complete
619
+ await vi.waitFor(() => {
620
+ expect(mockRequestPermission).toHaveBeenCalledWith({
621
+ permission: 'CAMERA',
622
+ })
623
+ })
624
+
625
+ // Input should NOT be clicked when permission request fails
626
+ expect(clickSpy).not.toHaveBeenCalled()
627
+
628
+ // Should show error message
629
+ await vi.waitFor(() => {
630
+ const errorMessage = document.querySelector(
631
+ '[data-testid="permission-error"]'
632
+ )
633
+ expect(errorMessage?.textContent).toBe('Camera permission not granted')
634
+ })
635
+ })
636
+
637
+ it('requests permission for front camera on Android', async () => {
638
+ window.minisParams = {...window.minisParams, platform: 'android'} as any
639
+ mockRequestPermission.mockResolvedValue({granted: true})
640
+
641
+ const TestComponent = () => {
642
+ const {openCamera} = useImagePickerContext()
643
+
644
+ return (
645
+ <button
646
+ type="button"
647
+ onClick={() =>
648
+ openCamera('front').catch(() => {
649
+ // Ignore errors from cleanup
650
+ })
651
+ }
652
+ >
653
+ Open Front Camera
654
+ </button>
655
+ )
656
+ }
657
+
658
+ const {container} = render(
659
+ <ImagePickerProvider>
660
+ <TestComponent />
661
+ </ImagePickerProvider>
662
+ )
663
+
664
+ const frontCameraInput = container.querySelector(
665
+ 'input[capture="user"]'
666
+ ) as HTMLInputElement
667
+ const clickSpy = vi.spyOn(frontCameraInput, 'click')
668
+
669
+ const button = screen.getByText('Open Front Camera')
670
+ fireEvent.click(button)
671
+
672
+ // Wait for permission request to complete
673
+ await vi.waitFor(() => {
674
+ expect(mockRequestPermission).toHaveBeenCalledWith({
675
+ permission: 'CAMERA',
676
+ })
677
+ })
678
+
679
+ // Front camera input should be clicked after permission is granted
680
+ await vi.waitFor(() => {
681
+ expect(clickSpy).toHaveBeenCalledTimes(1)
682
+ })
683
+ })
359
684
  })
360
685
 
361
686
  describe('Multiple Picker Handling', () => {
@@ -7,6 +7,8 @@ import React, {
7
7
  useMemo,
8
8
  } from 'react'
9
9
 
10
+ import {useRequestPermissions} from '../hooks/util/useRequestPermissions'
11
+
10
12
  export type CameraFacing = 'front' | 'back'
11
13
 
12
14
  interface ImagePickerContextValue {
@@ -30,6 +32,8 @@ interface ImagePickerProviderProps {
30
32
  children: React.ReactNode
31
33
  }
32
34
 
35
+ const isAndroid = () => window?.minisParams?.platform === 'android'
36
+
33
37
  export function ImagePickerProvider({children}: ImagePickerProviderProps) {
34
38
  const galleryInputRef = useRef<HTMLInputElement>(null)
35
39
  const frontCameraInputRef = useRef<HTMLInputElement>(null)
@@ -41,6 +45,8 @@ export function ImagePickerProvider({children}: ImagePickerProviderProps) {
41
45
  handler: () => void
42
46
  } | null>(null)
43
47
 
48
+ const {requestPermission} = useRequestPermissions()
49
+
44
50
  const cleanupCancelHandler = useCallback(() => {
45
51
  if (activeCancelHandlerRef.current) {
46
52
  const {input, handler} = activeCancelHandlerRef.current
@@ -106,9 +112,22 @@ export function ImagePickerProvider({children}: ImagePickerProviderProps) {
106
112
  input.addEventListener('cancel', handleCancel)
107
113
  activeCancelHandlerRef.current = {input, handler: handleCancel}
108
114
 
109
- input.click()
115
+ if (isAndroid()) {
116
+ // Android requires explicit camera permission for camera picker
117
+ requestPermission({permission: 'CAMERA'})
118
+ .then(() => {
119
+ // This will show both Camera and Gallery
120
+ input.click()
121
+ })
122
+ .catch(() => {
123
+ // Show only Gallery
124
+ input.click()
125
+ })
126
+ } else {
127
+ input.click()
128
+ }
110
129
  })
111
- }, [rejectPendingPromise, cleanupCancelHandler])
130
+ }, [rejectPendingPromise, cleanupCancelHandler, requestPermission])
112
131
 
113
132
  const openCamera = useCallback(
114
133
  (cameraFacing: CameraFacing = 'back') => {
@@ -143,10 +162,29 @@ export function ImagePickerProvider({children}: ImagePickerProviderProps) {
143
162
  input.addEventListener('cancel', handleCancel)
144
163
  activeCancelHandlerRef.current = {input, handler: handleCancel}
145
164
 
146
- input.click()
165
+ if (isAndroid()) {
166
+ // Android requires explicit camera permission
167
+ requestPermission({permission: 'CAMERA'})
168
+ .then(({granted}) => {
169
+ if (granted) {
170
+ input.click()
171
+ } else {
172
+ reject(new Error('Camera permission not granted'))
173
+ resolveRef.current = null
174
+ rejectRef.current = null
175
+ }
176
+ })
177
+ .catch(() => {
178
+ reject(new Error('Camera permission not granted'))
179
+ resolveRef.current = null
180
+ rejectRef.current = null
181
+ })
182
+ } else {
183
+ input.click()
184
+ }
147
185
  })
148
186
  },
149
- [rejectPendingPromise, cleanupCancelHandler]
187
+ [rejectPendingPromise, cleanupCancelHandler, requestPermission]
150
188
  )
151
189
 
152
190
  useEffect(() => {
@@ -1,15 +0,0 @@
1
- const n = {
2
- MiniConsent: "mini_consent",
3
- CameraConsent: "camera_consent",
4
- PhotoLibraryConsent: "photo_library_consent",
5
- MicrophoneConsent: "microphone_consent"
6
- }, e = {
7
- Granted: "granted",
8
- Dismissed: "dismissed",
9
- Idle: "idle"
10
- };
11
- export {
12
- n as Consent,
13
- e as ConsentStatus
14
- };
15
- //# sourceMappingURL=permissions.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"permissions.js","sources":["../../../../../shop-minis-platform/src/types/permissions.ts"],"sourcesContent":["export type Permission = 'CAMERA' | 'GALLERY' | 'MICROPHONE'\n\nexport interface MiniManifest {\n trusted_domains?: string[]\n permissions?: string[]\n [key: string]: any\n}\n\nexport const Consent = {\n MiniConsent: 'mini_consent',\n CameraConsent: 'camera_consent',\n PhotoLibraryConsent: 'photo_library_consent',\n MicrophoneConsent: 'microphone_consent',\n} as const\n\nexport const ConsentStatus = {\n Granted: 'granted',\n Dismissed: 'dismissed',\n Idle: 'idle',\n} as const\n\nexport type ConsentType = (typeof Consent)[keyof typeof Consent]\nexport type ConsentStatusType =\n (typeof ConsentStatus)[keyof typeof ConsentStatus]\n"],"names":["Consent","ConsentStatus"],"mappings":"AAQO,MAAMA,IAAU;AAAA,EACrB,aAAa;AAAA,EACb,eAAe;AAAA,EACf,qBAAqB;AAAA,EACrB,mBAAmB;AACrB,GAEaC,IAAgB;AAAA,EAC3B,SAAS;AAAA,EACT,WAAW;AAAA,EACX,MAAM;AACR;"}