@solucx/react-native-solucx-widget 0.1.16 → 0.2.0

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 +106 -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 +315 -0
  12. package/src/__tests__/e2e/widget-lifecycle.test.tsx +353 -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 +1 -1
  24. package/src/index.ts +4 -0
  25. package/src/interfaces/WidgetCallbacks.ts +15 -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.0",
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,171 @@
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 { buildWidgetURL } from "./utils/urlUtils";
10
+ import { WEB_VIEW_MESSAGE_LISTENER } from "./constants/webViewConstants";
11
+ import { ModalWidget } from "./components/ModalWidget";
12
+ import { InlineWidget } from "./components/InlineWidget";
13
+ import { OverlayWidget } from "./components/OverlayWidget";
14
+ import { requestWidgetUrl } from "./services/widgetBootstrapService";
13
15
 
14
16
  interface SoluCXWidgetProps {
15
17
  soluCXKey: SoluCXKey;
16
18
  type: WidgetType;
17
19
  data: WidgetData;
18
20
  options: WidgetOptions;
21
+ callbacks?: WidgetCallbacks;
19
22
  }
20
23
 
21
- export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({
22
- soluCXKey,
23
- type,
24
- data,
25
- options
26
- }) => {
24
+ export const SoluCXWidget: React.FC<SoluCXWidgetProps> = ({ soluCXKey, type, data, options, callbacks }) => {
27
25
  const webviewRef = useRef<WebView>(null);
28
- const { width } = Dimensions.get('window');
26
+ const { width } = Dimensions.get("window");
29
27
 
30
- const {
31
- widgetHeight,
32
- isWidgetVisible,
33
- setIsWidgetVisible,
34
- loadSavedData,
35
- resize,
36
- open,
37
- close,
38
- userId,
39
- } = useWidgetState(data, options, type);
28
+ const serializedData = JSON.stringify(data);
29
+ const normalizedData = useMemo(() => data, [serializedData]);
40
30
 
41
- const eventService = new WidgetEventService(setIsWidgetVisible, resize, open, userId, options);
31
+ const { widgetHeight, isWidgetVisible, setIsWidgetVisible, loadSavedData, resize, open, close, userId } =
32
+ useWidgetState(normalizedData, options, type);
42
33
 
43
- const uri = buildWidgetURL(soluCXKey, data);
44
- const isForm = Boolean(data.form_id);
34
+ const eventService = useMemo(
35
+ () => new WidgetEventService(setIsWidgetVisible, resize, userId, callbacks),
36
+ [setIsWidgetVisible, resize],
37
+ );
38
+
39
+ const validationService = useMemo(() => new WidgetValidationService(userId), [userId]);
40
+ const isForm = Boolean(normalizedData.form_id);
41
+ const [widgetUri, setWidgetUri] = useState<string | null>(() =>
42
+ isForm ? buildWidgetURL(soluCXKey, normalizedData) : null,
43
+ );
45
44
 
46
45
  useEffect(() => {
47
46
  loadSavedData();
48
47
  }, [loadSavedData]);
49
48
 
50
- const handleWebViewMessage = useCallback(async (message: string) => {
51
- if (message && message.length > 0) {
49
+ useEffect(() => {
50
+ let isActive = true;
51
+
52
+ const prepareWidgetURL = async () => {
53
+ if (isForm) {
54
+ setWidgetUri(buildWidgetURL(soluCXKey, normalizedData));
55
+ open(); // Show widget in form mode
56
+ return;
57
+ }
58
+
59
+ setWidgetUri(null);
60
+
52
61
  try {
53
- await eventService.handleMessage(message, isForm);
62
+ const widgetUrl = await requestWidgetUrl(soluCXKey, normalizedData, userId);
63
+ if (!isActive || !widgetUrl) return;
64
+
65
+ const result = await validationService.shouldDisplayWidget(options);
66
+ if (!isActive) return;
67
+
68
+ if (!result.canDisplay) {
69
+ const blockReason = result?.blockReason;
70
+ callbacks?.onBlock?.(blockReason);
71
+ setIsWidgetVisible(false);
72
+ return;
73
+ }
74
+
75
+ callbacks?.onPreOpen?.(userId);
76
+ open();
77
+ setWidgetUri(widgetUrl);
78
+ callbacks?.onOpened?.(userId);
54
79
  } catch (error) {
55
- console.error('Error handling widget message:', error);
80
+ if (isActive) {
81
+ callbacks?.onPingError?.(error);
82
+ setIsWidgetVisible(false);
83
+ }
56
84
  }
57
- }
58
- }, [eventService, isForm]);
85
+ };
86
+
87
+ prepareWidgetURL();
88
+
89
+ return () => {
90
+ isActive = false;
91
+ };
92
+ }, [validationService, soluCXKey, normalizedData, isForm, setIsWidgetVisible, options, open, userId, callbacks]);
93
+
94
+ const handleWebViewMessage = useCallback(
95
+ async (message: string) => {
96
+ if (message && message.length > 0) {
97
+ try {
98
+ await eventService.handleMessage(message, isForm);
99
+ } catch (error) {
100
+ console.error("Error handling widget message:", error);
101
+ }
102
+ }
103
+ },
104
+ [eventService, isForm],
105
+ );
59
106
 
60
107
  const handleWebViewLoad = useCallback(() => {
61
108
  webviewRef.current?.injectJavaScript(WEB_VIEW_MESSAGE_LISTENER);
62
109
  }, []);
63
110
 
64
111
  const handleClose = useCallback(() => {
65
- if (type === 'inline' || type === 'modal') {
112
+ if (type === "inline" || type === "modal") {
66
113
  close();
67
114
  }
68
115
  setIsWidgetVisible(false);
69
- }, [setIsWidgetVisible]);
116
+ }, [setIsWidgetVisible, close, type]);
70
117
 
118
+ const webViewStyle = [{ height: widgetHeight }, { width }];
71
119
 
72
- const webViewStyle = [
73
- { height: widgetHeight },
74
- { width }
75
- ];
120
+ if (!widgetUri) {
121
+ return null;
122
+ }
76
123
 
77
- if (type === 'modal') {
124
+ if (type === "modal") {
78
125
  return (
79
126
  <ModalWidget visible={isWidgetVisible} height={widgetHeight} onClose={handleClose}>
80
127
  <WebView
81
128
  ref={webviewRef}
82
129
  style={webViewStyle}
83
- source={{ uri }}
130
+ source={{ uri: widgetUri }}
84
131
  onLoadEnd={handleWebViewLoad}
85
132
  onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
86
- originWhitelist={['*']}
133
+ originWhitelist={["*"]}
87
134
  />
88
135
  </ModalWidget>
89
136
  );
90
137
  }
91
138
 
92
- if (type === 'inline') {
139
+ if (type === "inline") {
93
140
  return (
94
141
  <InlineWidget visible={isWidgetVisible} height={widgetHeight} onClose={handleClose}>
95
142
  <WebView
96
143
  ref={webviewRef}
97
144
  style={webViewStyle}
98
- source={{ uri }}
145
+ source={{ uri: widgetUri }}
99
146
  onLoadEnd={handleWebViewLoad}
100
147
  onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
101
- originWhitelist={['*']}
148
+ originWhitelist={["*"]}
102
149
  />
103
150
  </InlineWidget>
104
151
  );
105
152
  }
106
153
 
107
154
  return (
108
- <OverlayWidget visible={isWidgetVisible} width={width} height={widgetHeight} position={type} onClose={handleClose}>
155
+ <OverlayWidget
156
+ visible={isWidgetVisible}
157
+ width={width}
158
+ height={widgetHeight}
159
+ position={type}
160
+ onClose={handleClose}
161
+ >
109
162
  <WebView
110
163
  ref={webviewRef}
111
164
  style={webViewStyle}
112
- source={{ uri }}
165
+ source={{ uri: widgetUri }}
113
166
  onLoadEnd={handleWebViewLoad}
114
167
  onMessage={(event) => handleWebViewMessage(event.nativeEvent.data)}
115
- originWhitelist={['*']}
168
+ originWhitelist={["*"]}
116
169
  />
117
170
  </OverlayWidget>
118
171
  );
@@ -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
+ });