@solucx/react-native-solucx-widget 0.1.16 → 0.2.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.
Files changed (34) hide show
  1. package/package.json +31 -4
  2. package/src/SoluCXWidget.tsx +108 -53
  3. package/src/__mocks__/expo-modules-core-web.js +16 -0
  4. package/src/__mocks__/expo-modules-core.js +33 -0
  5. package/src/__tests__/ClientVersionCollector.test.ts +55 -0
  6. package/src/__tests__/CloseButton.test.tsx +47 -0
  7. package/src/__tests__/Constants.test.ts +17 -0
  8. package/src/__tests__/InlineWidget.rendering.test.tsx +81 -0
  9. package/src/__tests__/ModalWidget.rendering.test.tsx +157 -0
  10. package/src/__tests__/OverlayWidget.rendering.test.tsx +123 -0
  11. package/src/__tests__/SoluCXWidget.rendering.test.tsx +504 -0
  12. package/src/__tests__/e2e/widget-lifecycle.test.tsx +352 -0
  13. package/src/__tests__/integration/webview-communication-simple.test.tsx +147 -0
  14. package/src/__tests__/integration/webview-communication.test.tsx +417 -0
  15. package/src/__tests__/useDeviceInfoCollector.test.ts +109 -0
  16. package/src/__tests__/useWidgetState.test.ts +76 -84
  17. package/src/__tests__/widgetBootstrapService.test.ts +182 -0
  18. package/src/components/ModalWidget.tsx +3 -5
  19. package/src/components/OverlayWidget.tsx +1 -1
  20. package/src/constants/Constants.ts +4 -0
  21. package/src/constants/webViewConstants.ts +1 -0
  22. package/src/hooks/useDeviceInfoCollector.ts +67 -0
  23. package/src/hooks/useWidgetState.ts +4 -4
  24. package/src/index.ts +4 -0
  25. package/src/interfaces/WidgetCallbacks.ts +14 -0
  26. package/src/interfaces/index.ts +3 -2
  27. package/src/services/ClientVersionCollector.ts +15 -0
  28. package/src/services/storage.ts +2 -2
  29. package/src/services/widgetBootstrapService.ts +67 -0
  30. package/src/services/widgetEventService.ts +14 -30
  31. package/src/services/widgetValidationService.ts +29 -13
  32. package/src/setupTests.js +43 -0
  33. package/src/styles/widgetStyles.ts +1 -1
  34. package/src/utils/urlUtils.ts +2 -2
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@solucx/react-native-solucx-widget",
3
- "version": "0.1.16",
3
+ "version": "0.2.1",
4
4
  "description": "The React Native SDK for Solucx Widget",
5
5
  "main": "src/index",
6
6
  "author": " <> ()",
7
7
  "homepage": "#readme",
8
8
  "scripts": {
9
9
  "build": "tsc",
10
- "prepublishOnly": "npm run build"
10
+ "prepublishOnly": "npm run build",
11
+ "test": "jest"
11
12
  },
12
13
  "files": [
13
14
  "src/",
@@ -17,7 +18,33 @@
17
18
  "@react-native-async-storage/async-storage": "^2.2.0",
18
19
  "react": ">=18.0.0",
19
20
  "react-native": ">=0.72.0",
20
- "react-native-webview": "^13.16.0",
21
- "react-native-safe-area-context": "^5.6.1"
21
+ "react-native-safe-area-context": "^5.6.1",
22
+ "react-native-webview": "^13.16.0"
23
+ },
24
+ "devDependencies": {
25
+ "@babel/core": "^7.25.2",
26
+ "@testing-library/react-native": "^13.3.3",
27
+ "@types/jest": "29.5.14",
28
+ "@types/react": "~19.1.10",
29
+ "@types/react-native": "^0.72.8",
30
+ "jest": "~29.7.0",
31
+ "jest-expo": "~54.0.17",
32
+ "react-test-renderer": "19.1.0",
33
+ "typescript": "~5.9.2"
34
+ },
35
+ "jest": {
36
+ "preset": "jest-expo",
37
+ "setupFilesAfterEnv": [
38
+ "<rootDir>/src/setupTests.js"
39
+ ],
40
+ "testPathIgnorePatterns": [
41
+ "/lib/"
42
+ ],
43
+ "moduleNameMapper": {
44
+ "expo-modules-core/src/Refs": "<rootDir>/src/__mocks__/expo-modules-core.js",
45
+ "expo-modules-core/src/web/index.web": "<rootDir>/src/__mocks__/expo-modules-core-web.js",
46
+ "expo-modules-core/src/uuid/uuid.web": "<rootDir>/src/__mocks__/expo-modules-core.js",
47
+ "expo-modules-core$": "<rootDir>/src/__mocks__/expo-modules-core.js"
48
+ }
22
49
  }
23
50
  }
@@ -1,118 +1,173 @@
1
- import React, { useEffect, useRef, useCallback } from 'react';
2
- import { Dimensions } from 'react-native';
3
- import { WebView } from 'react-native-webview';
4
-
5
- import { SoluCXKey, WidgetData, WidgetOptions, WidgetType } from './interfaces';
6
- import { useWidgetState } from './hooks/useWidgetState';
7
- import { WidgetEventService } from './services/widgetEventService';
8
- import { buildWidgetURL } from './utils/urlUtils';
9
- import { WEB_VIEW_MESSAGE_LISTENER } from './constants/webViewConstants';
10
- import { ModalWidget } from './components/ModalWidget';
11
- import { InlineWidget } from './components/InlineWidget';
12
- import { OverlayWidget } from './components/OverlayWidget';
1
+ import React, { useEffect, useRef, useCallback, useState, useMemo } from "react";
2
+ import { Dimensions } from "react-native";
3
+ import { WebView } from "react-native-webview";
4
+
5
+ import type { SoluCXKey, WidgetData, WidgetOptions, WidgetType, WidgetCallbacks } from "./interfaces";
6
+ import { useWidgetState } from "./hooks/useWidgetState";
7
+ import { WidgetEventService } from "./services/widgetEventService";
8
+ import { WidgetValidationService } from "./services/widgetValidationService";
9
+ import { WEB_VIEW_MESSAGE_LISTENER } from "./constants/webViewConstants";
10
+ import { ModalWidget } from "./components/ModalWidget";
11
+ import { InlineWidget } from "./components/InlineWidget";
12
+ import { OverlayWidget } from "./components/OverlayWidget";
13
+ import { requestWidgetUrl } from "./services/widgetBootstrapService";
13
14
 
14
15
  interface SoluCXWidgetProps {
15
16
  soluCXKey: SoluCXKey;
16
17
  type: WidgetType;
17
18
  data: WidgetData;
18
19
  options: WidgetOptions;
20
+ callbacks?: WidgetCallbacks;
19
21
  }
20
22
 
21
- export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({
22
- soluCXKey,
23
- type,
24
- data,
25
- options
26
- }) => {
23
+ export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({ soluCXKey, type, data, options, callbacks }) => {
27
24
  const webviewRef = useRef<WebView>(null);
28
- const { width } = Dimensions.get('window');
25
+ const { width } = Dimensions.get("window");
26
+
27
+ if (!data) {
28
+ callbacks?.onError?.("Widget data is required but was not provided");
29
+ return;
30
+ }
29
31
 
30
- const {
31
- widgetHeight,
32
- isWidgetVisible,
33
- setIsWidgetVisible,
34
- loadSavedData,
35
- resize,
36
- open,
37
- close,
38
- userId,
39
- } = useWidgetState(data, options, type);
32
+ const serializedData = JSON.stringify(data);
33
+ const normalizedData = useMemo(() => data, [serializedData]);
40
34
 
41
- const eventService = new WidgetEventService(setIsWidgetVisible, resize, open, userId, options);
35
+ const { widgetHeight, isWidgetVisible, setIsWidgetVisible, loadSavedData, resize, open, close, userId } =
36
+ useWidgetState(normalizedData, options, type);
37
+
38
+ const eventService = useMemo(
39
+ () => new WidgetEventService(setIsWidgetVisible, resize, userId, callbacks),
40
+ [setIsWidgetVisible, resize],
41
+ );
42
42
 
43
- const uri = buildWidgetURL(soluCXKey, data);
44
- const isForm = Boolean(data.form_id);
43
+ const validationService = useMemo(() => new WidgetValidationService(userId), [userId]);
44
+ const isForm = Boolean(normalizedData.form_id);
45
+ const [widgetUri, setWidgetUri] = useState<string | null>(null);
45
46
 
46
47
  useEffect(() => {
47
48
  loadSavedData();
48
49
  }, [loadSavedData]);
49
50
 
50
- const handleWebViewMessage = useCallback(async (message: string) => {
51
- if (message && message.length > 0) {
51
+ useEffect(() => {
52
+ let isActive = true;
53
+
54
+ const prepareWidgetURL = async () => {
55
+ setWidgetUri(null);
56
+
52
57
  try {
53
- await eventService.handleMessage(message, isForm);
58
+ const widgetUrl = await requestWidgetUrl(soluCXKey, normalizedData, userId);
59
+ if (!isActive || !widgetUrl) return;
60
+
61
+ const result = await validationService.shouldDisplayWidget(options);
62
+ if (!isActive) return;
63
+
64
+ if (!result.canDisplay) {
65
+ const blockReason = result?.blockReason;
66
+ callbacks?.onBlock?.(blockReason);
67
+ setIsWidgetVisible(false);
68
+ return;
69
+ }
70
+
71
+ callbacks?.onPreOpen?.(userId);
72
+ open();
73
+ setWidgetUri(widgetUrl);
74
+ callbacks?.onOpened?.(userId);
54
75
  } catch (error) {
55
- console.error('Error handling widget message:', error);
76
+ if (isActive) {
77
+ let errorMessage = "Unknown error";
78
+
79
+ if (error instanceof Error) errorMessage = error.message;
80
+ else if (typeof error === "string") errorMessage = error;
81
+ else if (error !== null && typeof error === "object") errorMessage = JSON.stringify(error);
82
+
83
+ callbacks?.onError?.(errorMessage);
84
+ setIsWidgetVisible(false);
85
+ }
56
86
  }
57
- }
58
- }, [eventService, isForm]);
87
+ };
88
+
89
+ prepareWidgetURL();
90
+
91
+ return () => {
92
+ isActive = false;
93
+ };
94
+ }, [validationService, soluCXKey, normalizedData, isForm, setIsWidgetVisible, options, open, userId, callbacks]);
95
+
96
+ const handleWebViewMessage = useCallback(
97
+ async (message: string) => {
98
+ if (message && message.length > 0) {
99
+ try {
100
+ await eventService.handleMessage(message, isForm);
101
+ } catch (error) {
102
+ console.error("Error handling widget message:", error);
103
+ }
104
+ }
105
+ },
106
+ [eventService, isForm],
107
+ );
59
108
 
60
109
  const handleWebViewLoad = useCallback(() => {
61
110
  webviewRef.current?.injectJavaScript(WEB_VIEW_MESSAGE_LISTENER);
62
111
  }, []);
63
112
 
64
113
  const handleClose = useCallback(() => {
65
- if (type === 'inline' || type === 'modal') {
114
+ if (type === "inline" || type === "modal") {
66
115
  close();
67
116
  }
68
117
  setIsWidgetVisible(false);
69
- }, [setIsWidgetVisible]);
118
+ }, [setIsWidgetVisible, close, type]);
70
119
 
120
+ const webViewStyle = [{ height: widgetHeight }, { width }];
71
121
 
72
- const webViewStyle = [
73
- { height: widgetHeight },
74
- { width }
75
- ];
122
+ if (!widgetUri) {
123
+ return null;
124
+ }
76
125
 
77
- if (type === 'modal') {
126
+ if (type === "modal") {
78
127
  return (
79
128
  <ModalWidget visible={isWidgetVisible} height={widgetHeight} onClose={handleClose}>
80
129
  <WebView
81
130
  ref={webviewRef}
82
131
  style={webViewStyle}
83
- source={{ uri }}
132
+ source={{ uri: widgetUri }}
84
133
  onLoadEnd={handleWebViewLoad}
85
134
  onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
86
- originWhitelist={['*']}
135
+ originWhitelist={["*"]}
87
136
  />
88
137
  </ModalWidget>
89
138
  );
90
139
  }
91
140
 
92
- if (type === 'inline') {
141
+ if (type === "inline") {
93
142
  return (
94
143
  <InlineWidget visible={isWidgetVisible} height={widgetHeight} onClose={handleClose}>
95
144
  <WebView
96
145
  ref={webviewRef}
97
146
  style={webViewStyle}
98
- source={{ uri }}
147
+ source={{ uri: widgetUri }}
99
148
  onLoadEnd={handleWebViewLoad}
100
149
  onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
101
- originWhitelist={['*']}
150
+ originWhitelist={["*"]}
102
151
  />
103
152
  </InlineWidget>
104
153
  );
105
154
  }
106
155
 
107
156
  return (
108
- <OverlayWidget visible={isWidgetVisible} width={width} height={widgetHeight} position={type} onClose={handleClose}>
157
+ <OverlayWidget
158
+ visible={isWidgetVisible}
159
+ width={width}
160
+ height={widgetHeight}
161
+ position={type}
162
+ onClose={handleClose}
163
+ >
109
164
  <WebView
110
165
  ref={webviewRef}
111
166
  style={webViewStyle}
112
- source={{ uri }}
167
+ source={{ uri: widgetUri }}
113
168
  onLoadEnd={handleWebViewLoad}
114
169
  onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
115
- originWhitelist={['*']}
170
+ originWhitelist={["*"]}
116
171
  />
117
172
  </OverlayWidget>
118
173
  );
@@ -0,0 +1,16 @@
1
+ // Mock for expo-modules-core/src/web/index.web
2
+ // Provides stubs for web globals normally installed via JSI
3
+
4
+ if (typeof globalThis.expo === 'undefined') {
5
+ globalThis.expo = {
6
+ EventEmitter: class EventEmitter {
7
+ constructor() { this._listeners = {}; }
8
+ addListener() { return { remove: () => {} }; }
9
+ removeAllListeners() {}
10
+ emit() {}
11
+ },
12
+ NativeModule: class NativeModule {},
13
+ SharedObject: class SharedObject {},
14
+ modules: {},
15
+ };
16
+ }
@@ -0,0 +1,33 @@
1
+ // Comprehensive mock for expo-modules-core
2
+ const EventEmitter = class EventEmitter {
3
+ constructor() { this._listeners = {}; }
4
+ addListener() { return { remove: () => {} }; }
5
+ removeAllListeners() {}
6
+ emit() {}
7
+ };
8
+
9
+ const NativeModule = class NativeModule {};
10
+ const SharedObject = class SharedObject {};
11
+
12
+ const createSnapshotFriendlyRef = () => {
13
+ const ref = { current: null };
14
+ Object.defineProperty(ref, 'toJSON', {
15
+ value: () => '[React.ref]',
16
+ });
17
+ return ref;
18
+ };
19
+
20
+ module.exports = {
21
+ EventEmitter,
22
+ NativeModule,
23
+ SharedObject,
24
+ NativeModulesProxy: {},
25
+ requireOptionalNativeModule: () => null,
26
+ requireNativeModule: (name) => { throw new Error(`Cannot find native module '${name}'`); },
27
+ requireNativeViewManager: () => ({}),
28
+ uuid: {
29
+ v4: () => 'mock-uuid-v4',
30
+ v5: () => 'mock-uuid-v5',
31
+ },
32
+ createSnapshotFriendlyRef,
33
+ };
@@ -0,0 +1,55 @@
1
+ describe('ClientVersionCollector', () => {
2
+ beforeEach(() => {
3
+ jest.resetModules();
4
+ });
5
+
6
+ it('should get version from react-native-device-info default export when available', () => {
7
+ jest.doMock('react-native-device-info', () => ({
8
+ default: {
9
+ getVersion: () => '3.0.0',
10
+ },
11
+ }), { virtual: true });
12
+
13
+ const { getClientVersion } = require('../services/ClientVersionCollector');
14
+ expect(getClientVersion()).toBe('3.0.0');
15
+ });
16
+
17
+ it('should get version from react-native-device-info direct export when default is not available', () => {
18
+ jest.doMock('react-native-device-info', () => ({
19
+ getVersion: () => '4.0.0',
20
+ }), { virtual: true });
21
+
22
+ const { getClientVersion } = require('../services/ClientVersionCollector');
23
+ expect(getClientVersion()).toBe('4.0.0');
24
+ });
25
+
26
+ it('should return "unknown" when react-native-device-info is not available', () => {
27
+ jest.doMock('react-native-device-info', () => {
28
+ throw new Error('Module not found');
29
+ }, { virtual: true });
30
+
31
+ const { getClientVersion } = require('../services/ClientVersionCollector');
32
+ expect(getClientVersion()).toBe('unknown');
33
+ });
34
+
35
+ it('should return "unknown" when react-native-device-info has no getVersion method', () => {
36
+ jest.doMock('react-native-device-info', () => ({
37
+ default: {},
38
+ }), { virtual: true });
39
+
40
+ const { getClientVersion } = require('../services/ClientVersionCollector');
41
+ expect(getClientVersion()).toBe('unknown');
42
+ });
43
+
44
+ it('should prefer default.getVersion when both default and direct getVersion are available', () => {
45
+ jest.doMock('react-native-device-info', () => ({
46
+ default: {
47
+ getVersion: () => '5.0.0',
48
+ },
49
+ getVersion: () => '6.0.0',
50
+ }), { virtual: true });
51
+
52
+ const { getClientVersion } = require('../services/ClientVersionCollector');
53
+ expect(getClientVersion()).toBe('5.0.0');
54
+ });
55
+ });
@@ -0,0 +1,47 @@
1
+ import React from 'react';
2
+ import { render, fireEvent } from '@testing-library/react-native';
3
+ import { CloseButton } from '../components/CloseButton';
4
+
5
+ describe('CloseButton', () => {
6
+ it('should render close button text when visible is true', () => {
7
+ const { getByText } = render(
8
+ <CloseButton onPress={jest.fn()} visible={true} />
9
+ );
10
+
11
+ expect(getByText('✕')).toBeTruthy();
12
+ });
13
+
14
+ it('should not render when visible is false', () => {
15
+ const { queryByText } = render(
16
+ <CloseButton onPress={jest.fn()} visible={false} />
17
+ );
18
+
19
+ expect(queryByText('✕')).toBeNull();
20
+ });
21
+
22
+ it('should render by default when visible prop is omitted', () => {
23
+ const { getByText } = render(
24
+ <CloseButton onPress={jest.fn()} />
25
+ );
26
+
27
+ expect(getByText('✕')).toBeTruthy();
28
+ });
29
+
30
+ it('should call onPress when pressed', () => {
31
+ const mockOnPress = jest.fn();
32
+ const { getByText } = render(
33
+ <CloseButton onPress={mockOnPress} visible={true} />
34
+ );
35
+
36
+ fireEvent.press(getByText('✕'));
37
+
38
+ expect(mockOnPress).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ it('should not call onPress when not pressed', () => {
42
+ const mockOnPress = jest.fn();
43
+ render(<CloseButton onPress={mockOnPress} visible={true} />);
44
+
45
+ expect(mockOnPress).not.toHaveBeenCalled();
46
+ });
47
+ });
@@ -0,0 +1,17 @@
1
+ import { SDK_NAME, SDK_VERSION } from '../constants/Constants';
2
+
3
+ describe('Constants', () => {
4
+ describe('SDK_NAME', () => {
5
+ it('should have the correct SDK name', () => {
6
+ expect(SDK_NAME).toBe('rn-widget-sdk');
7
+ });
8
+ });
9
+
10
+ describe('SDK_VERSION', () => {
11
+ it('should import version from package.json', () => {
12
+ expect(SDK_VERSION).toBeDefined();
13
+ expect(typeof SDK_VERSION).toBe('string');
14
+ expect(SDK_VERSION).toMatch(/^\d+\.\d+\.\d+$/); // Verifica formato semver
15
+ });
16
+ });
17
+ });
@@ -0,0 +1,81 @@
1
+ import React from 'react';
2
+ import { render, fireEvent } from '@testing-library/react-native';
3
+ import { Text } from 'react-native';
4
+ import { InlineWidget } from '../components/InlineWidget';
5
+
6
+ describe('InlineWidget rendering', () => {
7
+ it('should render children when visible is true', () => {
8
+ const { getByText } = render(
9
+ <InlineWidget visible={true} height={300}>
10
+ <Text>Inline Survey</Text>
11
+ </InlineWidget>
12
+ );
13
+
14
+ expect(getByText('Inline Survey')).toBeTruthy();
15
+ });
16
+
17
+ it('should render close button when visible is true', () => {
18
+ const { getByText } = render(
19
+ <InlineWidget visible={true} height={300}>
20
+ <Text>Content</Text>
21
+ </InlineWidget>
22
+ );
23
+
24
+ expect(getByText('✕')).toBeTruthy();
25
+ });
26
+
27
+ it('should call onClose when close button is pressed', () => {
28
+ const mockOnClose = jest.fn();
29
+ const { getByText } = render(
30
+ <InlineWidget visible={true} height={300} onClose={mockOnClose}>
31
+ <Text>Content</Text>
32
+ </InlineWidget>
33
+ );
34
+
35
+ fireEvent.press(getByText('✕'));
36
+
37
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
38
+ });
39
+
40
+ it('should still render content when visible is false', () => {
41
+ const { getByText } = render(
42
+ <InlineWidget visible={false} height={300}>
43
+ <Text>Hidden Content</Text>
44
+ </InlineWidget>
45
+ );
46
+
47
+ expect(getByText('Hidden Content')).toBeTruthy();
48
+ });
49
+
50
+ it('should hide close button when visible is false', () => {
51
+ const { queryByText } = render(
52
+ <InlineWidget visible={false} height={300}>
53
+ <Text>Content</Text>
54
+ </InlineWidget>
55
+ );
56
+
57
+ expect(queryByText('✕')).toBeNull();
58
+ });
59
+
60
+ it('should not throw when onClose is not provided and close is pressed', () => {
61
+ const { getByText } = render(
62
+ <InlineWidget visible={true} height={300}>
63
+ <Text>Content</Text>
64
+ </InlineWidget>
65
+ );
66
+
67
+ expect(() => fireEvent.press(getByText('✕'))).not.toThrow();
68
+ });
69
+
70
+ it('should render multiple children', () => {
71
+ const { getByText } = render(
72
+ <InlineWidget visible={true} height={300}>
73
+ <Text>Child A</Text>
74
+ <Text>Child B</Text>
75
+ </InlineWidget>
76
+ );
77
+
78
+ expect(getByText('Child A')).toBeTruthy();
79
+ expect(getByText('Child B')).toBeTruthy();
80
+ });
81
+ });