@capillarytech/creatives-library 8.0.316-alpha.0 → 8.0.316-alpha.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.316-alpha.0",
4
+ "version": "8.0.316-alpha.1",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/utils/common.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  REGISTRATION_CUSTOM_FIELD,
17
17
  JP_LOCALE_HIDE_FEATURE,
18
18
  ENABLE_CUSTOMER_BARCODE_TAG,
19
+ AI_CONTENT_BOT_DISABLED,
19
20
  BADGES_UI_ENABLED,
20
21
  BADGES_ENROLL,
21
22
  EMAIL_UNSUBSCRIBE_TAG_MANDATORY,
@@ -126,6 +127,11 @@ export const hasCustomerBarcodeFeatureEnabled = Auth.hasFeatureAccess.bind(
126
127
  ENABLE_CUSTOMER_BARCODE_TAG,
127
128
  );
128
129
 
130
+ export const isAiContentBotDisabled = Auth.hasFeatureAccess.bind(
131
+ null,
132
+ AI_CONTENT_BOT_DISABLED,
133
+ );
134
+
129
135
  // Note: The "EMAIL_UNSUBSCRIBE_TAG_MANDATORY" feature flag determines if the Unsubscribe tag in email is optional.
130
136
  // When this flag is enabled for an org, the Unsubscribe tag is NOT mandatory in the email flow.
131
137
  // This is as per the requirement in the tech doc:
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useRef } from 'react';
2
2
  import PropTypes from 'prop-types';
3
3
  import { FormattedMessage } from 'react-intl';
4
4
  import CapInput from '@capillarytech/cap-ui-library/CapInput';
@@ -8,7 +8,9 @@ import CapError from '@capillarytech/cap-ui-library/CapError';
8
8
  import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
9
9
  import CapEmojiPicker from '@capillarytech/cap-ui-library/CapEmojiPicker';
10
10
  import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
11
+ import CapAskAira from '@capillarytech/cap-ui-library/CapAskAira';
11
12
  import { MESSAGE_MAX_LENGTH, SHOW_CHARACTER_COUNT } from '../../constants';
13
+ import { useAiraTriggerPosition } from '../hooks/useAiraTriggerPosition';
12
14
 
13
15
  /**
14
16
  * MessageSection component - Message textarea with tags, emoji picker, and character count
@@ -23,7 +25,15 @@ export const MessageSection = ({
23
25
  messageCountRef,
24
26
  messageTextAreaRef,
25
27
  handleMessageTextAreaRef,
28
+ isAiContentBotDisabled,
26
29
  }) => {
30
+ const messageInputWrapperRef = useRef(null);
31
+ const airaRootStyle = useAiraTriggerPosition({
32
+ wrapperRef: messageInputWrapperRef,
33
+ inputRef: messageTextAreaRef,
34
+ error,
35
+ });
36
+
27
37
  const renderCharacterCount = () => {
28
38
  if (!SHOW_CHARACTER_COUNT) return null;
29
39
 
@@ -50,29 +60,40 @@ export const MessageSection = ({
50
60
  <CapHeading type="h4" className="webpush-message">
51
61
  <FormattedMessage {...messages.message} />
52
62
  </CapHeading>
53
- <CapEmojiPicker.Wrapper
54
- value={value}
55
- onChange={onChange}
56
- textAreaRef={messageTextAreaRef}
57
- >
58
- <CapInput.TextArea
59
- id="webpush-message-input"
63
+ <div className="webpush-message-input-wrapper" ref={messageInputWrapperRef}>
64
+ <CapEmojiPicker.Wrapper
60
65
  value={value}
61
66
  onChange={onChange}
62
- placeholder={formatMessage(messages.messagePlaceholder)}
63
- size="default"
64
- isRequired
65
- autosize={{ minRows: 3, maxRows: 5 }}
66
- setInputRef={handleMessageTextAreaRef}
67
- errorMessage={
68
- error && (
69
- <CapError className="webpush-template-message-error">
70
- {error}
71
- </CapError>
72
- )
73
- }
74
- />
75
- </CapEmojiPicker.Wrapper>
67
+ textAreaRef={messageTextAreaRef}
68
+ >
69
+ <CapInput.TextArea
70
+ id="webpush-message-input"
71
+ value={value}
72
+ onChange={onChange}
73
+ placeholder={formatMessage(messages.messagePlaceholder)}
74
+ size="default"
75
+ isRequired
76
+ autosize={{ minRows: 3, maxRows: 5 }}
77
+ setInputRef={handleMessageTextAreaRef}
78
+ errorMessage={
79
+ error && (
80
+ <CapError className="webpush-template-message-error">
81
+ {error}
82
+ </CapError>
83
+ )
84
+ }
85
+ />
86
+ </CapEmojiPicker.Wrapper>
87
+ {!isAiContentBotDisabled && (
88
+ <CapAskAira.ContentGenerationBot
89
+ text={value || ''}
90
+ setText={(text) => onChange(text)}
91
+ iconPlacement="float-br"
92
+ iconSize="1.6rem"
93
+ rootStyle={airaRootStyle}
94
+ />
95
+ )}
96
+ </div>
76
97
  {renderCharacterCount()}
77
98
  </CapRow>
78
99
  <CapDivider className="webpush-message-divider" />
@@ -90,6 +111,7 @@ MessageSection.propTypes = {
90
111
  messageCountRef: PropTypes.object,
91
112
  messageTextAreaRef: PropTypes.object,
92
113
  handleMessageTextAreaRef: PropTypes.func.isRequired,
114
+ isAiContentBotDisabled: PropTypes.bool,
93
115
  };
94
116
 
95
117
  MessageSection.defaultProps = {
@@ -97,6 +119,7 @@ MessageSection.defaultProps = {
97
119
  tagList: null,
98
120
  messageCountRef: null,
99
121
  messageTextAreaRef: null,
122
+ isAiContentBotDisabled: false,
100
123
  };
101
124
 
102
125
  export default MessageSection;
@@ -8,6 +8,7 @@ import CapError from '@capillarytech/cap-ui-library/CapError';
8
8
  import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
9
9
  import CapEmojiPicker from '@capillarytech/cap-ui-library/CapEmojiPicker';
10
10
  import CapDivider from '@capillarytech/cap-ui-library/CapDivider';
11
+ import CapAskAira from '@capillarytech/cap-ui-library/CapAskAira';
11
12
  import { MESSAGE_MAX_LENGTH, SHOW_CHARACTER_COUNT } from '../../constants';
12
13
 
13
14
  describe('MessageSection', () => {
@@ -45,6 +46,7 @@ describe('MessageSection', () => {
45
46
  messageCountRef: null,
46
47
  messageTextAreaRef: null,
47
48
  handleMessageTextAreaRef: mockHandleMessageTextAreaRef,
49
+ isAiContentBotDisabled: true,
48
50
  };
49
51
 
50
52
  beforeEach(() => {
@@ -76,6 +78,32 @@ describe('MessageSection', () => {
76
78
  expect(emojiPicker.prop('value')).toBe('Test Message');
77
79
  });
78
80
 
81
+ it('should not render aiRA button when feature is disabled', () => {
82
+ const wrapper = mountWithIntl(<MessageSection {...defaultProps} />);
83
+ expect(wrapper.find(CapAskAira.ContentGenerationBot).exists()).toBe(false);
84
+ });
85
+
86
+ it('should render aiRA button when feature is enabled', () => {
87
+ const wrapper = mountWithIntl(
88
+ <MessageSection {...defaultProps} isAiContentBotDisabled={false} />
89
+ );
90
+ const airaBot = wrapper.find(CapAskAira.ContentGenerationBot);
91
+ expect(airaBot.exists()).toBe(true);
92
+ expect(airaBot.prop('text')).toBe('Test Message');
93
+ expect(airaBot.prop('rootStyle')).toMatchObject({
94
+ right: '2.5rem',
95
+ left: 'auto',
96
+ });
97
+ });
98
+
99
+ it('should render aiRA and emoji controls together', () => {
100
+ const wrapper = mountWithIntl(
101
+ <MessageSection {...defaultProps} isAiContentBotDisabled={false} />
102
+ );
103
+ expect(wrapper.find(CapAskAira.ContentGenerationBot).exists()).toBe(true);
104
+ expect(wrapper.find(CapEmojiPicker.Wrapper).exists()).toBe(true);
105
+ });
106
+
79
107
  it('should render CapInput.TextArea with correct props', () => {
80
108
  const wrapper = mountWithIntl(<MessageSection {...defaultProps} />);
81
109
  const textArea = wrapper.find(CapInput.TextArea);
@@ -145,6 +173,23 @@ describe('MessageSection', () => {
145
173
  const textArea = wrapper.find(CapInput.TextArea);
146
174
  expect(textArea.prop('errorMessage')).toBeDefined();
147
175
  });
176
+
177
+ it('should keep aiRA visible and anchored when error is present', () => {
178
+ const errorMessage = 'Message is required';
179
+ const wrapper = mountWithIntl(
180
+ <MessageSection
181
+ {...defaultProps}
182
+ error={errorMessage}
183
+ isAiContentBotDisabled={false}
184
+ />
185
+ );
186
+ const airaBot = wrapper.find(CapAskAira.ContentGenerationBot);
187
+ expect(airaBot.exists()).toBe(true);
188
+ expect(airaBot.prop('rootStyle')).toMatchObject({
189
+ right: '2.5rem',
190
+ left: 'auto',
191
+ });
192
+ });
148
193
  });
149
194
 
150
195
  describe('Character Count', () => {
@@ -216,6 +261,15 @@ describe('MessageSection', () => {
216
261
  emojiPicker.prop('onChange')('New Message');
217
262
  expect(mockOnChange).toHaveBeenCalledWith('New Message');
218
263
  });
264
+
265
+ it('should call onChange when aiRA returns generated text', () => {
266
+ const wrapper = mountWithIntl(
267
+ <MessageSection {...defaultProps} isAiContentBotDisabled={false} />
268
+ );
269
+ const airaBot = wrapper.find(CapAskAira.ContentGenerationBot);
270
+ airaBot.prop('setText')('Generated by aiRA');
271
+ expect(mockOnChange).toHaveBeenCalledWith('Generated by aiRA');
272
+ });
219
273
  });
220
274
 
221
275
  describe('Default Props', () => {
@@ -18,28 +18,32 @@ exports[`MessageSection Rendering should render correctly with default props 1`]
18
18
  values={Object {}}
19
19
  />
20
20
  </CapHeading>
21
- <InjectIntl(Wrapper)
22
- onChange={[MockFunction]}
23
- textAreaRef={null}
24
- value="Test Message"
21
+ <div
22
+ className="webpush-message-input-wrapper"
25
23
  >
26
- <_Class
27
- autosize={
28
- Object {
29
- "maxRows": 5,
30
- "minRows": 3,
31
- }
32
- }
33
- errorMessage=""
34
- id="webpush-message-input"
35
- isRequired={true}
36
- labelPosition="top"
24
+ <InjectIntl(Wrapper)
37
25
  onChange={[MockFunction]}
38
- setInputRef={[MockFunction]}
39
- size="default"
26
+ textAreaRef={null}
40
27
  value="Test Message"
41
- />
42
- </InjectIntl(Wrapper)>
28
+ >
29
+ <_Class
30
+ autosize={
31
+ Object {
32
+ "maxRows": 5,
33
+ "minRows": 3,
34
+ }
35
+ }
36
+ errorMessage=""
37
+ id="webpush-message-input"
38
+ isRequired={true}
39
+ labelPosition="top"
40
+ onChange={[MockFunction]}
41
+ setInputRef={[MockFunction]}
42
+ size="default"
43
+ value="Test Message"
44
+ />
45
+ </InjectIntl(Wrapper)>
46
+ </div>
43
47
  <CapLabel
44
48
  className="webpush-character-count"
45
49
  type="label2"
@@ -0,0 +1,80 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ const DEFAULT_BOTTOM = '0.83rem';
4
+
5
+ export const getBottomOffset = (wrapperElement, inputElement, hasError) => {
6
+ if (!wrapperElement || !inputElement) {
7
+ return DEFAULT_BOTTOM;
8
+ }
9
+
10
+ if (!hasError) {
11
+ return DEFAULT_BOTTOM;
12
+ }
13
+
14
+ const inputRect = inputElement.getBoundingClientRect();
15
+ const wrapperRect = wrapperElement.getBoundingClientRect();
16
+ const inputBottomFromWrapperTop = inputRect.bottom - wrapperRect.top;
17
+ const distanceFromWrapperBottom = wrapperRect.height - inputBottomFromWrapperTop;
18
+
19
+ return `${Math.max(distanceFromWrapperBottom + 4, 4)}px`;
20
+ };
21
+
22
+ export const useAiraTriggerPosition = ({
23
+ wrapperRef,
24
+ inputRef,
25
+ rightOffset = '2.5rem',
26
+ error,
27
+ }) => {
28
+ const [bottomOffset, setBottomOffset] = useState(DEFAULT_BOTTOM);
29
+
30
+ useEffect(() => {
31
+ const wrapperElement = wrapperRef?.current;
32
+ if (!wrapperElement) {
33
+ setBottomOffset(DEFAULT_BOTTOM);
34
+ return undefined;
35
+ }
36
+
37
+ const updatePosition = () => {
38
+ const inputElement = inputRef?.current;
39
+ const hasError = Boolean(
40
+ error
41
+ || wrapperElement.querySelector('.error-message')
42
+ || wrapperElement.querySelector('.webpush-template-message-error'),
43
+ );
44
+ const nextOffset = getBottomOffset(wrapperElement, inputElement, hasError);
45
+ setBottomOffset((prevOffset) => (
46
+ prevOffset === nextOffset ? prevOffset : nextOffset
47
+ ));
48
+ };
49
+
50
+ const rafId = requestAnimationFrame(updatePosition);
51
+ let resizeObserver = null;
52
+
53
+ if (window.ResizeObserver) {
54
+ resizeObserver = new window.ResizeObserver(() => {
55
+ updatePosition();
56
+ });
57
+ resizeObserver.observe(wrapperElement);
58
+ if (inputRef?.current) {
59
+ resizeObserver.observe(inputRef.current);
60
+ }
61
+ }
62
+
63
+ return () => {
64
+ cancelAnimationFrame(rafId);
65
+ if (resizeObserver) {
66
+ resizeObserver.disconnect();
67
+ }
68
+ };
69
+ }, [wrapperRef, inputRef, error]);
70
+
71
+ return useMemo(
72
+ () => ({
73
+ bottom: bottomOffset,
74
+ right: rightOffset,
75
+ left: 'auto',
76
+ }),
77
+ [bottomOffset, rightOffset],
78
+ );
79
+ };
80
+
@@ -0,0 +1,216 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { getBottomOffset, useAiraTriggerPosition } from './useAiraTriggerPosition';
3
+
4
+ describe('useAiraTriggerPosition', () => {
5
+ let resizeObserverCallback;
6
+ let observeMock;
7
+ let disconnectMock;
8
+
9
+ beforeEach(() => {
10
+ observeMock = jest.fn();
11
+ disconnectMock = jest.fn();
12
+ resizeObserverCallback = null;
13
+
14
+ global.requestAnimationFrame = jest.fn((cb) => {
15
+ cb();
16
+ return 1;
17
+ });
18
+ global.cancelAnimationFrame = jest.fn();
19
+
20
+ global.ResizeObserver = jest.fn((cb) => {
21
+ resizeObserverCallback = cb;
22
+ return {
23
+ observe: observeMock,
24
+ disconnect: disconnectMock,
25
+ };
26
+ });
27
+ });
28
+
29
+ afterEach(() => {
30
+ jest.clearAllMocks();
31
+ global.ResizeObserver = jest.fn((cb) => {
32
+ resizeObserverCallback = cb;
33
+ return {
34
+ observe: observeMock,
35
+ disconnect: disconnectMock,
36
+ };
37
+ });
38
+ });
39
+
40
+ const createRefs = () => {
41
+ const wrapperElement = document.createElement('div');
42
+ const inputElement = document.createElement('textarea');
43
+ wrapperElement.appendChild(inputElement);
44
+
45
+ const wrapperRef = { current: wrapperElement };
46
+ const inputRef = { current: inputElement };
47
+
48
+ return { wrapperElement, inputElement, wrapperRef, inputRef };
49
+ };
50
+
51
+ it('should return default style when there is no error', () => {
52
+ const { wrapperRef, inputRef } = createRefs();
53
+
54
+ const { result } = renderHook(() => useAiraTriggerPosition({
55
+ wrapperRef,
56
+ inputRef,
57
+ rightOffset: '2.5rem',
58
+ value: 'hello',
59
+ error: '',
60
+ }));
61
+
62
+ expect(result.current).toEqual({
63
+ bottom: '0.83rem',
64
+ right: '2.5rem',
65
+ left: 'auto',
66
+ });
67
+ });
68
+
69
+ it('should compute bottom offset when error is present', () => {
70
+ const { wrapperElement, inputElement, wrapperRef, inputRef } = createRefs();
71
+
72
+ const errorElement = document.createElement('div');
73
+ errorElement.className = 'webpush-template-message-error';
74
+ wrapperElement.appendChild(errorElement);
75
+
76
+ wrapperElement.getBoundingClientRect = jest.fn(() => ({
77
+ top: 0,
78
+ bottom: 160,
79
+ height: 160,
80
+ left: 0,
81
+ right: 100,
82
+ width: 100,
83
+ }));
84
+ inputElement.getBoundingClientRect = jest.fn(() => ({
85
+ top: 20,
86
+ bottom: 100,
87
+ height: 80,
88
+ left: 0,
89
+ right: 100,
90
+ width: 100,
91
+ }));
92
+
93
+ const { result } = renderHook(() => useAiraTriggerPosition({
94
+ wrapperRef,
95
+ inputRef,
96
+ rightOffset: '2.5rem',
97
+ value: 'hello',
98
+ error: 'Required',
99
+ }));
100
+
101
+ expect(result.current).toEqual({
102
+ bottom: '64px',
103
+ right: '2.5rem',
104
+ left: 'auto',
105
+ });
106
+ });
107
+
108
+ it('should observe wrapper and input for layout changes', () => {
109
+ const { wrapperElement, inputElement, wrapperRef, inputRef } = createRefs();
110
+
111
+ renderHook(() => useAiraTriggerPosition({
112
+ wrapperRef,
113
+ inputRef,
114
+ rightOffset: '2.5rem',
115
+ value: '',
116
+ error: '',
117
+ }));
118
+
119
+ expect(observeMock).toHaveBeenCalledWith(wrapperElement);
120
+ expect(observeMock).toHaveBeenCalledWith(inputElement);
121
+ expect(typeof resizeObserverCallback).toBe('function');
122
+ });
123
+
124
+ it('should invoke updatePosition when ResizeObserver fires', () => {
125
+ const { wrapperElement, inputElement, wrapperRef, inputRef } = createRefs();
126
+
127
+ renderHook(() => useAiraTriggerPosition({
128
+ wrapperRef,
129
+ inputRef,
130
+ rightOffset: '2.5rem',
131
+ error: '',
132
+ }));
133
+
134
+ expect(resizeObserverCallback).toBeTruthy();
135
+ resizeObserverCallback();
136
+ expect(observeMock).toHaveBeenCalledWith(wrapperElement);
137
+ expect(observeMock).toHaveBeenCalledWith(inputElement);
138
+ });
139
+
140
+ it('should not observe input when inputRef has no current element', () => {
141
+ const { wrapperElement, wrapperRef } = createRefs();
142
+
143
+ renderHook(() => useAiraTriggerPosition({
144
+ wrapperRef,
145
+ inputRef: { current: null },
146
+ rightOffset: '2.5rem',
147
+ error: '',
148
+ }));
149
+
150
+ expect(observeMock).toHaveBeenCalledWith(wrapperElement);
151
+ expect(observeMock).toHaveBeenCalledTimes(1);
152
+ });
153
+
154
+ it('should use default right offset when omitted', () => {
155
+ const { wrapperRef, inputRef } = createRefs();
156
+
157
+ const { result } = renderHook(() => useAiraTriggerPosition({
158
+ wrapperRef,
159
+ inputRef,
160
+ error: '',
161
+ }));
162
+
163
+ expect(result.current.right).toBe('2.5rem');
164
+ });
165
+
166
+ it('should use default bottom when wrapper ref is not attached', () => {
167
+ const wrapperRef = { current: null };
168
+ const inputRef = { current: null };
169
+
170
+ const { result } = renderHook(() => useAiraTriggerPosition({
171
+ wrapperRef,
172
+ inputRef,
173
+ rightOffset: '2.5rem',
174
+ error: '',
175
+ }));
176
+
177
+ expect(result.current).toEqual({
178
+ bottom: '0.83rem',
179
+ right: '2.5rem',
180
+ left: 'auto',
181
+ });
182
+ });
183
+
184
+ it('should skip disconnect when ResizeObserver is unavailable', () => {
185
+ const saved = global.ResizeObserver;
186
+ delete global.ResizeObserver;
187
+
188
+ const { wrapperRef, inputRef } = createRefs();
189
+
190
+ const { unmount } = renderHook(() => useAiraTriggerPosition({
191
+ wrapperRef,
192
+ inputRef,
193
+ rightOffset: '2.5rem',
194
+ error: '',
195
+ }));
196
+
197
+ expect(disconnectMock).not.toHaveBeenCalled();
198
+ unmount();
199
+ expect(disconnectMock).not.toHaveBeenCalled();
200
+
201
+ global.ResizeObserver = saved;
202
+ });
203
+ });
204
+
205
+ describe('getBottomOffset', () => {
206
+ it('returns default when wrapper element is missing', () => {
207
+ const input = document.createElement('textarea');
208
+ expect(getBottomOffset(null, input, true)).toBe('0.83rem');
209
+ });
210
+
211
+ it('returns default when input element is missing', () => {
212
+ const wrapper = document.createElement('div');
213
+ expect(getBottomOffset(wrapper, null, true)).toBe('0.83rem');
214
+ });
215
+ });
216
+
@@ -72,6 +72,7 @@ import {
72
72
  } from '../../Cap/selectors';
73
73
  import './index.scss';
74
74
  import { WEBPUSH } from '../../CreativesContainer/constants';
75
+ import { isAiContentBotDisabled } from '../../../utils/common';
75
76
 
76
77
  // Memoized TagList wrapper components for better performance
77
78
  const MemoizedTagList = memo(({
@@ -156,6 +157,7 @@ const WebPushCreate = ({
156
157
  restrictPersonalization = false,
157
158
  }) => {
158
159
  const { formatMessage } = intl;
160
+ const aiContentBotDisabled = isAiContentBotDisabled();
159
161
 
160
162
  // Form state - kept in main component for now
161
163
  const [templateName, setTemplateName] = useState('');
@@ -997,6 +999,7 @@ const WebPushCreate = ({
997
999
  messageCountRef={messageCountRef}
998
1000
  messageTextAreaRef={messageTextAreaRef}
999
1001
  handleMessageTextAreaRef={handleMessageTextAreaRef}
1002
+ isAiContentBotDisabled={aiContentBotDisabled}
1000
1003
  />
1001
1004
  <MediaSection
1002
1005
  mediaType={mediaType}
@@ -81,6 +81,11 @@
81
81
  margin-bottom: -2.5625rem; // approx -41px
82
82
  }
83
83
 
84
+ .webpush-message-input-wrapper {
85
+ position: relative;
86
+ width: 100%;
87
+ }
88
+
84
89
  .webpush-buttons-section-heading {
85
90
  font-weight: $FONT_WEIGHT_MEDIUM;
86
91
  color: $CAP_G16;