@shopify/shop-minis-react 0.2.1 → 0.2.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.
@@ -9,6 +9,14 @@ const path = require('path')
9
9
  // Load the hook-scopes map
10
10
  const hookScopesMap = require('../../generated-hook-maps/hook-scopes-map.json')
11
11
 
12
+ // Load the component-scopes map (may not exist yet)
13
+ let componentScopesMap = {}
14
+ try {
15
+ componentScopesMap = require('../../generated-hook-maps/component-scopes-map.json')
16
+ } catch (err) {
17
+ // Component scopes map doesn't exist yet, that's okay
18
+ }
19
+
12
20
  // Module-level cache for manifest.json to avoid repeated file I/O
13
21
  // ESLint reuses the same rule module across all files, so this cache
14
22
  // persists for the entire lint run, dramatically improving performance
@@ -73,7 +81,7 @@ module.exports = {
73
81
  fixable: 'code',
74
82
  messages: {
75
83
  missingScope:
76
- 'Hook "{{hookName}}" requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array.',
84
+ '{{source}} requires scope "{{scope}}" in src/manifest.json. Add "{{scope}}" to the "scopes" array.',
77
85
  missingPermission:
78
86
  '{{reason}} requires permission "{{permission}}" in src/manifest.json. Add "{{permission}}" to the "permissions" array.',
79
87
  missingTrustedDomain:
@@ -93,6 +101,7 @@ module.exports = {
93
101
  let manifest = null
94
102
  let manifestParseError = null
95
103
  const usedHooks = new Set()
104
+ const usedComponents = new Set()
96
105
  const requiredPermissions = new Set()
97
106
  const requiredDomains = new Set()
98
107
  const fixedIssues = new Set()
@@ -171,6 +180,35 @@ module.exports = {
171
180
  })
172
181
  })
173
182
  }
183
+
184
+ // Check if it's a component that requires scopes
185
+ // We need to check all possible paths where the component might be defined
186
+ const possibleComponentPaths = Object.keys(
187
+ componentScopesMap
188
+ ).filter(componentPath => {
189
+ // Extract the component name from the path (e.g., "commerce/add-to-cart" -> "AddToCartButton")
190
+ // We'll check if the import name matches common component naming patterns
191
+ const pathParts = componentPath.split('/')
192
+ const fileName = pathParts[pathParts.length - 1]
193
+ // Convert kebab-case or snake_case to PascalCase
194
+ const componentName = fileName
195
+ .split(/[-_]/)
196
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
197
+ .join('')
198
+ return (
199
+ componentName === importedName || fileName === importedName
200
+ )
201
+ })
202
+
203
+ if (possibleComponentPaths.length > 0) {
204
+ possibleComponentPaths.forEach(componentPath => {
205
+ usedComponents.add({
206
+ path: componentPath,
207
+ name: importedName,
208
+ node,
209
+ })
210
+ })
211
+ }
174
212
  }
175
213
  })
176
214
  }
@@ -408,6 +446,7 @@ module.exports = {
408
446
  if (
409
447
  manifestParseError &&
410
448
  (usedHooks.size > 0 ||
449
+ usedComponents.size > 0 ||
411
450
  requiredPermissions.size > 0 ||
412
451
  requiredDomains.size > 0)
413
452
  ) {
@@ -447,6 +486,47 @@ module.exports = {
447
486
  })
448
487
  })
449
488
 
489
+ // Check scopes for components
490
+ usedComponents.forEach(
491
+ ({path: componentPath, name: componentName, node}) => {
492
+ const componentData = componentScopesMap[componentPath]
493
+ if (
494
+ !componentData ||
495
+ !componentData.scopes ||
496
+ componentData.scopes.length === 0
497
+ ) {
498
+ return
499
+ }
500
+
501
+ const requiredScopes = componentData.scopes
502
+
503
+ if (!manifest) {
504
+ issues.push({
505
+ messageId: 'manifestNotFound',
506
+ data: {
507
+ hookName: componentName,
508
+ scopes: requiredScopes.join(', '),
509
+ },
510
+ })
511
+ return
512
+ }
513
+
514
+ const manifestScopes = manifest.scopes || []
515
+
516
+ requiredScopes.forEach(requiredScope => {
517
+ if (!manifestScopes.includes(requiredScope)) {
518
+ issues.push({
519
+ type: 'scope',
520
+ scope: requiredScope,
521
+ componentName,
522
+ componentPath,
523
+ node,
524
+ })
525
+ }
526
+ })
527
+ }
528
+ )
529
+
450
530
  // Check permissions
451
531
  requiredPermissions.forEach(({permission, reason, node}) => {
452
532
  if (!manifest) {
@@ -513,11 +593,15 @@ module.exports = {
513
593
 
514
594
  // Only report if not fixed
515
595
  if (!fixedIssues.has(issueKey)) {
596
+ // Determine if this is from a hook or component
597
+ const sourceName = issue.hookName || issue.componentName
598
+ const sourceType = issue.hookName ? 'Hook' : 'Component'
599
+
516
600
  context.report({
517
601
  loc: {line: 1, column: 0},
518
602
  messageId: 'missingScope',
519
603
  data: {
520
- hookName: issue.hookName,
604
+ source: `${sourceType} "${sourceName}"`,
521
605
  scope: issue.scope,
522
606
  },
523
607
  })
@@ -0,0 +1,18 @@
1
+ {
2
+ "commerce/product-card": {
3
+ "scopes": [
4
+ "product_list:write"
5
+ ],
6
+ "hooks": [
7
+ "useSavedProductsActions"
8
+ ]
9
+ },
10
+ "commerce/product-link": {
11
+ "scopes": [
12
+ "product_list:write"
13
+ ],
14
+ "hooks": [
15
+ "useSavedProductsActions"
16
+ ]
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shopify/shop-minis-react",
3
3
  "license": "SEE LICENSE IN LICENSE.txt",
4
- "version": "0.2.1",
4
+ "version": "0.2.3",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "engines": {
@@ -79,7 +79,6 @@
79
79
  },
80
80
  "devDependencies": {
81
81
  "@playwright/test": "^1.54.2",
82
- "@shopify/generate-docs": "^0.16.6",
83
82
  "@storybook/addon-docs": "^9.0.16",
84
83
  "@storybook/react-vite": "^9.0.16",
85
84
  "@testing-library/jest-dom": "^6.6.4",
@@ -17,6 +17,14 @@ vi.mock('../hooks/util/useRequestPermissions', () => ({
17
17
  }),
18
18
  }))
19
19
 
20
+ // Mock useReportInteraction hook
21
+ const mockReportInteraction = vi.fn()
22
+ vi.mock('../internal/useReportInteraction', () => ({
23
+ useReportInteraction: () => ({
24
+ reportInteraction: mockReportInteraction,
25
+ }),
26
+ }))
27
+
20
28
  describe('ImagePickerProvider', () => {
21
29
  const originalMinisParams = window.minisParams
22
30
 
@@ -24,6 +32,8 @@ describe('ImagePickerProvider', () => {
24
32
  vi.clearAllMocks()
25
33
  // Default to granting permission
26
34
  mockRequestPermission.mockResolvedValue({granted: true})
35
+ // Clear interaction reporting mock
36
+ mockReportInteraction.mockClear()
27
37
  })
28
38
 
29
39
  afterEach(() => {
@@ -716,4 +726,385 @@ describe('ImagePickerProvider', () => {
716
726
  expect(galleryInput.value).toBe('')
717
727
  })
718
728
  })
729
+
730
+ describe('Interaction Reporting', () => {
731
+ describe('Gallery interactions', () => {
732
+ it('reports image_picker_open when gallery is opened', async () => {
733
+ mockRequestPermission.mockResolvedValue({granted: true})
734
+
735
+ const TestComponent = () => {
736
+ const {openGallery} = useImagePickerContext()
737
+
738
+ return (
739
+ <button
740
+ type="button"
741
+ onClick={() =>
742
+ openGallery().catch(() => {
743
+ // Ignore errors from cleanup
744
+ })
745
+ }
746
+ >
747
+ Open Gallery
748
+ </button>
749
+ )
750
+ }
751
+
752
+ render(
753
+ <ImagePickerProvider>
754
+ <TestComponent />
755
+ </ImagePickerProvider>
756
+ )
757
+
758
+ const button = screen.getByText('Open Gallery')
759
+ fireEvent.click(button)
760
+
761
+ // Wait for interaction to be reported
762
+ await vi.waitFor(() => {
763
+ expect(mockReportInteraction).toHaveBeenCalledWith({
764
+ interactionType: 'image_picker_open',
765
+ })
766
+ })
767
+ })
768
+
769
+ it('reports image_picker_success when file is selected', async () => {
770
+ mockRequestPermission.mockResolvedValue({granted: true})
771
+
772
+ const TestComponent = () => {
773
+ const {openGallery} = useImagePickerContext()
774
+
775
+ return (
776
+ <button
777
+ type="button"
778
+ onClick={() =>
779
+ openGallery().catch(() => {
780
+ // Ignore errors from cleanup
781
+ })
782
+ }
783
+ >
784
+ Open Gallery
785
+ </button>
786
+ )
787
+ }
788
+
789
+ const {container} = render(
790
+ <ImagePickerProvider>
791
+ <TestComponent />
792
+ </ImagePickerProvider>
793
+ )
794
+
795
+ const galleryInput = container.querySelector(
796
+ 'input[type="file"]:not([capture])'
797
+ ) as HTMLInputElement
798
+
799
+ const button = screen.getByText('Open Gallery')
800
+ fireEvent.click(button)
801
+
802
+ // Wait for permission and initial interaction
803
+ await vi.waitFor(() => {
804
+ expect(mockReportInteraction).toHaveBeenCalledWith({
805
+ interactionType: 'image_picker_open',
806
+ })
807
+ })
808
+
809
+ // Clear mock to check only for success interaction
810
+ mockReportInteraction.mockClear()
811
+
812
+ // Simulate file selection
813
+ const file = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
814
+ Object.defineProperty(galleryInput, 'files', {
815
+ value: [file],
816
+ configurable: true,
817
+ })
818
+
819
+ await act(async () => {
820
+ fireEvent.change(galleryInput)
821
+ })
822
+
823
+ // Check success was reported
824
+ await vi.waitFor(() => {
825
+ expect(mockReportInteraction).toHaveBeenCalledWith({
826
+ interactionType: 'image_picker_success',
827
+ })
828
+ })
829
+ })
830
+
831
+ it('reports image_picker_error when interrupted by new picker', async () => {
832
+ mockRequestPermission.mockResolvedValue({granted: true})
833
+
834
+ const TestComponent = () => {
835
+ const {openGallery, openCamera} = useImagePickerContext()
836
+
837
+ return (
838
+ <>
839
+ <button
840
+ type="button"
841
+ onClick={() =>
842
+ openGallery().catch(() => {
843
+ // Expected to fail
844
+ })
845
+ }
846
+ >
847
+ Open Gallery
848
+ </button>
849
+ <button
850
+ type="button"
851
+ onClick={() =>
852
+ openCamera().catch(() => {
853
+ // Ignore errors
854
+ })
855
+ }
856
+ >
857
+ Open Camera
858
+ </button>
859
+ </>
860
+ )
861
+ }
862
+
863
+ render(
864
+ <ImagePickerProvider>
865
+ <TestComponent />
866
+ </ImagePickerProvider>
867
+ )
868
+
869
+ // Open gallery first
870
+ const galleryButton = screen.getByText('Open Gallery')
871
+ fireEvent.click(galleryButton)
872
+
873
+ // Wait for gallery open to be reported
874
+ await vi.waitFor(() => {
875
+ expect(mockReportInteraction).toHaveBeenCalledWith({
876
+ interactionType: 'image_picker_open',
877
+ })
878
+ })
879
+
880
+ mockReportInteraction.mockClear()
881
+
882
+ // Open camera to interrupt gallery
883
+ const cameraButton = screen.getByText('Open Camera')
884
+ fireEvent.click(cameraButton)
885
+
886
+ // Check error was reported for interrupted gallery operation
887
+ await vi.waitFor(() => {
888
+ expect(mockReportInteraction).toHaveBeenCalledWith({
889
+ interactionType: 'image_picker_error',
890
+ interactionValue:
891
+ 'New file picker opened before previous completed',
892
+ })
893
+ })
894
+ })
895
+ })
896
+
897
+ describe('Camera interactions', () => {
898
+ it('reports camera_open when camera is opened', async () => {
899
+ mockRequestPermission.mockResolvedValue({granted: true})
900
+
901
+ const TestComponent = () => {
902
+ const {openCamera} = useImagePickerContext()
903
+
904
+ return (
905
+ <button
906
+ type="button"
907
+ onClick={() =>
908
+ openCamera().catch(() => {
909
+ // Ignore errors from cleanup
910
+ })
911
+ }
912
+ >
913
+ Open Camera
914
+ </button>
915
+ )
916
+ }
917
+
918
+ render(
919
+ <ImagePickerProvider>
920
+ <TestComponent />
921
+ </ImagePickerProvider>
922
+ )
923
+
924
+ const button = screen.getByText('Open Camera')
925
+ fireEvent.click(button)
926
+
927
+ // Wait for interaction to be reported
928
+ await vi.waitFor(() => {
929
+ expect(mockReportInteraction).toHaveBeenCalledWith({
930
+ interactionType: 'camera_open',
931
+ })
932
+ })
933
+ })
934
+
935
+ it('reports camera_success when photo is captured', async () => {
936
+ mockRequestPermission.mockResolvedValue({granted: true})
937
+
938
+ const TestComponent = () => {
939
+ const {openCamera} = useImagePickerContext()
940
+
941
+ return (
942
+ <button
943
+ type="button"
944
+ onClick={() =>
945
+ openCamera().catch(() => {
946
+ // Ignore errors from cleanup
947
+ })
948
+ }
949
+ >
950
+ Open Camera
951
+ </button>
952
+ )
953
+ }
954
+
955
+ const {container} = render(
956
+ <ImagePickerProvider>
957
+ <TestComponent />
958
+ </ImagePickerProvider>
959
+ )
960
+
961
+ const cameraInput = container.querySelector(
962
+ 'input[capture="environment"]'
963
+ ) as HTMLInputElement
964
+
965
+ const button = screen.getByText('Open Camera')
966
+ fireEvent.click(button)
967
+
968
+ // Wait for camera open to be reported
969
+ await vi.waitFor(() => {
970
+ expect(mockReportInteraction).toHaveBeenCalledWith({
971
+ interactionType: 'camera_open',
972
+ })
973
+ })
974
+
975
+ mockReportInteraction.mockClear()
976
+
977
+ // Simulate photo capture
978
+ const file = new File(['photo'], 'photo.jpg', {type: 'image/jpeg'})
979
+ Object.defineProperty(cameraInput, 'files', {
980
+ value: [file],
981
+ configurable: true,
982
+ })
983
+
984
+ await act(async () => {
985
+ fireEvent.change(cameraInput)
986
+ })
987
+
988
+ // Check success was reported
989
+ await vi.waitFor(() => {
990
+ expect(mockReportInteraction).toHaveBeenCalledWith({
991
+ interactionType: 'camera_success',
992
+ })
993
+ })
994
+ })
995
+
996
+ it('reports camera_error when interrupted by new picker', async () => {
997
+ mockRequestPermission.mockResolvedValue({granted: true})
998
+
999
+ const TestComponent = () => {
1000
+ const {openCamera, openGallery} = useImagePickerContext()
1001
+
1002
+ return (
1003
+ <>
1004
+ <button
1005
+ type="button"
1006
+ onClick={() =>
1007
+ openCamera().catch(() => {
1008
+ // Expected to fail
1009
+ })
1010
+ }
1011
+ >
1012
+ Open Camera
1013
+ </button>
1014
+ <button
1015
+ type="button"
1016
+ onClick={() =>
1017
+ openGallery().catch(() => {
1018
+ // Ignore errors
1019
+ })
1020
+ }
1021
+ >
1022
+ Open Gallery
1023
+ </button>
1024
+ </>
1025
+ )
1026
+ }
1027
+
1028
+ render(
1029
+ <ImagePickerProvider>
1030
+ <TestComponent />
1031
+ </ImagePickerProvider>
1032
+ )
1033
+
1034
+ // Open camera first
1035
+ const cameraButton = screen.getByText('Open Camera')
1036
+ fireEvent.click(cameraButton)
1037
+
1038
+ // Wait for camera open to be reported
1039
+ await vi.waitFor(() => {
1040
+ expect(mockReportInteraction).toHaveBeenCalledWith({
1041
+ interactionType: 'camera_open',
1042
+ })
1043
+ })
1044
+
1045
+ mockReportInteraction.mockClear()
1046
+
1047
+ // Open gallery to interrupt camera
1048
+ const galleryButton = screen.getByText('Open Gallery')
1049
+ fireEvent.click(galleryButton)
1050
+
1051
+ // Check error was reported for interrupted camera operation
1052
+ await vi.waitFor(() => {
1053
+ expect(mockReportInteraction).toHaveBeenCalledWith({
1054
+ interactionType: 'camera_error',
1055
+ interactionValue:
1056
+ 'New file picker opened before previous completed',
1057
+ })
1058
+ })
1059
+ })
1060
+
1061
+ it('reports camera_error when permission is denied', async () => {
1062
+ mockRequestPermission.mockResolvedValue({granted: false})
1063
+
1064
+ const TestComponent = () => {
1065
+ const {openCamera} = useImagePickerContext()
1066
+
1067
+ return (
1068
+ <button
1069
+ type="button"
1070
+ onClick={() =>
1071
+ openCamera().catch(() => {
1072
+ // Expected to fail
1073
+ })
1074
+ }
1075
+ >
1076
+ Open Camera
1077
+ </button>
1078
+ )
1079
+ }
1080
+
1081
+ render(
1082
+ <ImagePickerProvider>
1083
+ <TestComponent />
1084
+ </ImagePickerProvider>
1085
+ )
1086
+
1087
+ const button = screen.getByText('Open Camera')
1088
+ fireEvent.click(button)
1089
+
1090
+ // Wait for permission request
1091
+ await vi.waitFor(() => {
1092
+ expect(mockRequestPermission).toHaveBeenCalled()
1093
+ })
1094
+
1095
+ // Camera open should be reported
1096
+ expect(mockReportInteraction).toHaveBeenCalledWith({
1097
+ interactionType: 'camera_open',
1098
+ })
1099
+
1100
+ // Error interaction should be reported for permission denial
1101
+ await vi.waitFor(() => {
1102
+ expect(mockReportInteraction).toHaveBeenCalledWith({
1103
+ interactionType: 'camera_error',
1104
+ interactionValue: 'Camera permission not granted',
1105
+ })
1106
+ })
1107
+ })
1108
+ })
1109
+ })
719
1110
  })