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