@pushwoosh/dumb-components 1.1.73 → 1.1.75

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.
@@ -1,5 +1,6 @@
1
1
  import React, { useRef } from 'react';
2
2
  import { Color } from '@pushwoosh/kit-constants';
3
+ import { Vertical } from '@pushwoosh/kit-helpers';
3
4
  import { DropdownIcon, EditIcon } from '@pushwoosh/kit-icons';
4
5
  import { Button } from '../Button';
5
6
  import { useOnClickOutside } from '../hooks';
@@ -42,6 +43,8 @@ export function EditableField({
42
43
  internalWindowRef: rootRef,
43
44
  loading: loading,
44
45
  zIndex: zIndex
46
+ }, React.createElement(Vertical, {
47
+ "$padding": 16
45
48
  }, title && React.createElement(TitleBox, null, title), children, React.createElement(ButtonsGroup, {
46
49
  "$isLoading": loading
47
50
  }, React.createElement(Button, {
@@ -56,5 +59,5 @@ export function EditableField({
56
59
  size: "compact",
57
60
  isDisabled: loading,
58
61
  onClick: onCancel
59
- }, "Cancel"))));
62
+ }, "Cancel")))));
60
63
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "1.1.73",
3
+ "version": "1.1.75",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -13,7 +13,8 @@
13
13
  "check": "npm run check:lint && npm run check:types",
14
14
  "check:lint": "echo \"Run check:lint\" && eslint src",
15
15
  "check:lint-fix": "echo \"Run check:lint\" && eslint src --fix",
16
- "check:types": "echo \"Run check:types\" && pushwoosh-engine lib:check-types"
16
+ "check:types": "echo \"Run check:types\" && pushwoosh-engine lib:check-types",
17
+ "test": "jest"
17
18
  },
18
19
  "pre-commit": "check",
19
20
  "engines": {
@@ -23,16 +24,22 @@
23
24
  "devDependencies": {
24
25
  "@pushwoosh/frontend-builder-engine": "^1.0.21",
25
26
  "@svgr/webpack": "^8.1.0",
27
+ "@testing-library/jest-dom": "^6.9.1",
28
+ "@testing-library/react": "^16.3.0",
29
+ "@types/jest": "^30.0.0",
26
30
  "@types/lodash": "^4.17.13",
27
31
  "@types/react": "^18.2.69",
28
32
  "@types/react-dom": "^18.2.22",
29
33
  "@types/react-transition-group": "^4.4.12",
30
34
  "@types/styled-components": "^5.1.34",
31
35
  "glob": "^11.0.3",
36
+ "jest": "^30.2.0",
37
+ "jest-environment-jsdom": "^30.2.0",
32
38
  "pre-commit": "^1.2.2",
33
39
  "react": "^18.2.0",
34
40
  "react-dom": "^18.2.0",
35
- "styled-components": "^5.3.11"
41
+ "styled-components": "^5.3.11",
42
+ "ts-jest": "^29.4.6"
36
43
  },
37
44
  "dependencies": {
38
45
  "@codemirror/lang-html": "^6.4.9",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,123 @@
1
+ import { filters } from '../liquid/filters';
2
+ const createContext = (language = 'en', timezone = 'UTC') => ({
3
+ values: {},
4
+ language,
5
+ timezone
6
+ });
7
+ const scalar = value => ({
8
+ type: 'Scalar',
9
+ value
10
+ });
11
+ describe('filters', () => {
12
+ describe('capitalize', () => {
13
+ test('capitalizes first letter', () => {
14
+ expect(filters.capitalize('hello', [], createContext())).toBe('Hello');
15
+ });
16
+ test('handles empty string', () => {
17
+ expect(filters.capitalize('', [], createContext())).toBe('');
18
+ });
19
+ test('returns non-string as is', () => {
20
+ expect(filters.capitalize(42, [], createContext())).toBe(42);
21
+ });
22
+ });
23
+ describe('upperCase', () => {
24
+ test('converts to uppercase', () => {
25
+ expect(filters.upperCase('hello', [], createContext())).toBe('HELLO');
26
+ });
27
+ test('returns non-string as is', () => {
28
+ expect(filters.upperCase(42, [], createContext())).toBe(42);
29
+ });
30
+ });
31
+ describe('lowerCase', () => {
32
+ test('converts to lowercase', () => {
33
+ expect(filters.lowerCase('HELLO', [], createContext())).toBe('hello');
34
+ });
35
+ test('returns non-string as is', () => {
36
+ expect(filters.lowerCase(42, [], createContext())).toBe(42);
37
+ });
38
+ });
39
+ describe('pluralize', () => {
40
+ describe('English', () => {
41
+ const ctx = createContext('en');
42
+ test('returns singular for 1', () => {
43
+ expect(filters.pluralize(1, [scalar('item'), scalar('items')], ctx)).toBe('item');
44
+ });
45
+ test('returns plural for 0', () => {
46
+ expect(filters.pluralize(0, [scalar('item'), scalar('items')], ctx)).toBe('items');
47
+ });
48
+ test('returns plural for 2+', () => {
49
+ expect(filters.pluralize(5, [scalar('item'), scalar('items')], ctx)).toBe('items');
50
+ });
51
+ });
52
+ describe('Russian', () => {
53
+ const ctx = createContext('ru');
54
+ test('returns first form for 1', () => {
55
+ expect(filters.pluralize(1, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблоко');
56
+ });
57
+ test('returns second form for 2-4', () => {
58
+ expect(filters.pluralize(2, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблока');
59
+ expect(filters.pluralize(3, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблока');
60
+ expect(filters.pluralize(4, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблока');
61
+ });
62
+ test('returns third form for 5-20', () => {
63
+ expect(filters.pluralize(5, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблок');
64
+ expect(filters.pluralize(11, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблок');
65
+ expect(filters.pluralize(20, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблок');
66
+ });
67
+ test('returns first form for 21', () => {
68
+ expect(filters.pluralize(21, [scalar('яблоко'), scalar('яблока'), scalar('яблок')], ctx)).toBe('яблоко');
69
+ });
70
+ });
71
+ test('returns value as is for non-number', () => {
72
+ expect(filters.pluralize('not a number', [scalar('a'), scalar('b')], createContext())).toBe('not a number');
73
+ });
74
+ });
75
+ describe('formatNumber', () => {
76
+ test('formats integer with spaces', () => {
77
+ expect(filters.formatNumber('1000000', [], createContext())).toBe('1 000 000');
78
+ });
79
+ test('formats with decimal places', () => {
80
+ expect(filters.formatNumber('1234.5', [scalar(2)], createContext())).toBe('1 234,50');
81
+ });
82
+ test('truncates excess decimal places', () => {
83
+ expect(filters.formatNumber('1234.5678', [scalar(2)], createContext())).toBe('1 234,56');
84
+ });
85
+ test('handles integer without decimal', () => {
86
+ expect(filters.formatNumber('1234', [], createContext())).toBe('1 234');
87
+ });
88
+ });
89
+ describe('formatDate', () => {
90
+ const testDate = '2024-06-15T12:30:00Z';
91
+ test('formats date with context timezone', () => {
92
+ const result = filters.formatDate(testDate, [], createContext('en', 'UTC'));
93
+ expect(result).toBe('Jun 15, 2024');
94
+ });
95
+ test('formats date with short format', () => {
96
+ const result = filters.formatDate(testDate, [scalar('short')], createContext('en', 'UTC'));
97
+ expect(result).toMatch(/\d{1,2}\/\d{1,2}\/\d{2,4}/);
98
+ });
99
+ test('formats date with long format', () => {
100
+ const result = filters.formatDate(testDate, [scalar('long')], createContext('en', 'UTC'));
101
+ expect(result).toContain('June');
102
+ expect(result).toContain('2024');
103
+ });
104
+ test('formats date with different locale', () => {
105
+ const result = filters.formatDate(testDate, [], createContext('ru', 'UTC'));
106
+ expect(result).toContain('2024');
107
+ });
108
+ });
109
+ describe('formatDateTime', () => {
110
+ const testDate = '2024-06-15T12:30:00Z';
111
+ test('formats date and time with context timezone', () => {
112
+ const result = filters.formatDateTime(testDate, [], createContext('en', 'UTC'));
113
+ expect(result).toContain('Jun');
114
+ expect(result).toContain('2024');
115
+ expect(result).toContain('12:30');
116
+ });
117
+ test('formats date and time with short format', () => {
118
+ const result = filters.formatDateTime(testDate, [scalar('short')], createContext('en', 'UTC'));
119
+ expect(result).toMatch(/\d{1,2}\/\d{1,2}\/\d{2,4}/);
120
+ expect(result).toContain(':');
121
+ });
122
+ });
123
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import { smartSplit, smartSplitAtFirstDelimiter, ensureNumber, ensureString, parseScalarOrVariable, addSpaceAroundOperators, unpackScalarOrValue, decodeHtmlEntities } from '../liquid/helpers';
2
+ describe('smartSplit', () => {
3
+ test('splits simple string by delimiter', () => {
4
+ expect(smartSplit('a,b,c', ',')).toEqual(['a', 'b', 'c']);
5
+ });
6
+ test('preserves quoted strings', () => {
7
+ expect(smartSplit('"a,b",c', ',')).toEqual(['"a,b"', 'c']);
8
+ });
9
+ test('handles escaped quotes in strings', () => {
10
+ expect(smartSplit('"a\\"b",c', ',')).toEqual(['"a\\"b"', 'c']);
11
+ });
12
+ test('handles empty string', () => {
13
+ expect(smartSplit('', ',')).toEqual([]);
14
+ });
15
+ test('handles single element', () => {
16
+ expect(smartSplit('abc', ',')).toEqual(['abc']);
17
+ });
18
+ });
19
+ describe('smartSplitAtFirstDelimiter', () => {
20
+ test('splits at first delimiter', () => {
21
+ expect(smartSplitAtFirstDelimiter('a:b:c', ':')).toEqual(['a', 'b:c']);
22
+ });
23
+ test('returns original string when no delimiter', () => {
24
+ expect(smartSplitAtFirstDelimiter('abc', ':')).toEqual(['abc']);
25
+ });
26
+ test('preserves quoted strings before delimiter', () => {
27
+ expect(smartSplitAtFirstDelimiter('"a:b":c', ':')).toEqual(['"a:b"', 'c']);
28
+ });
29
+ });
30
+ describe('ensureNumber', () => {
31
+ test('returns number as is', () => {
32
+ expect(ensureNumber(42)).toBe(42);
33
+ });
34
+ test('parses string number', () => {
35
+ expect(ensureNumber('42')).toBe(42);
36
+ });
37
+ test('parses float', () => {
38
+ expect(ensureNumber('3.14')).toBe(3.14);
39
+ });
40
+ test('returns 0 for NaN', () => {
41
+ expect(ensureNumber('not a number')).toBe(0);
42
+ });
43
+ test('returns 0 for undefined', () => {
44
+ expect(ensureNumber(undefined)).toBe(0);
45
+ });
46
+ });
47
+ describe('ensureString', () => {
48
+ test('returns string as is', () => {
49
+ expect(ensureString('hello')).toBe('hello');
50
+ });
51
+ test('converts number to string', () => {
52
+ expect(ensureString(42)).toBe('42');
53
+ });
54
+ test('returns empty string for null', () => {
55
+ expect(ensureString(null)).toBe('');
56
+ });
57
+ test('returns empty string for undefined', () => {
58
+ expect(ensureString(undefined)).toBe('');
59
+ });
60
+ });
61
+ describe('parseScalarOrVariable', () => {
62
+ test('parses quoted string as scalar', () => {
63
+ expect(parseScalarOrVariable('"hello"')).toEqual({
64
+ type: 'Scalar',
65
+ value: 'hello'
66
+ });
67
+ });
68
+ test('parses boolean true', () => {
69
+ expect(parseScalarOrVariable('true')).toEqual({
70
+ type: 'Scalar',
71
+ value: true
72
+ });
73
+ });
74
+ test('parses boolean false', () => {
75
+ expect(parseScalarOrVariable('false')).toEqual({
76
+ type: 'Scalar',
77
+ value: false
78
+ });
79
+ });
80
+ test('parses integer as scalar', () => {
81
+ expect(parseScalarOrVariable('42')).toEqual({
82
+ type: 'Scalar',
83
+ value: 42
84
+ });
85
+ });
86
+ test('parses float as scalar', () => {
87
+ expect(parseScalarOrVariable('3.14')).toEqual({
88
+ type: 'Scalar',
89
+ value: 3.14
90
+ });
91
+ });
92
+ test('parses identifier as variable', () => {
93
+ expect(parseScalarOrVariable('name')).toEqual({
94
+ type: 'Variable',
95
+ name: 'name'
96
+ });
97
+ });
98
+ });
99
+ describe('addSpaceAroundOperators', () => {
100
+ test('adds spaces around ==', () => {
101
+ expect(addSpaceAroundOperators('a==b')).toBe('a == b');
102
+ });
103
+ test('adds spaces around !=', () => {
104
+ expect(addSpaceAroundOperators('a!=b')).toBe('a != b');
105
+ });
106
+ test('adds spaces around >=', () => {
107
+ expect(addSpaceAroundOperators('a>=b')).toBe('a >= b');
108
+ });
109
+ });
110
+ describe('unpackScalarOrValue', () => {
111
+ const context = {
112
+ values: {
113
+ name: 'John'
114
+ },
115
+ language: 'en',
116
+ timezone: 'UTC'
117
+ };
118
+ test('returns scalar value directly', () => {
119
+ expect(unpackScalarOrValue({
120
+ type: 'Scalar',
121
+ value: 'hello'
122
+ }, context)).toBe('hello');
123
+ });
124
+ test('returns variable value from context', () => {
125
+ expect(unpackScalarOrValue({
126
+ type: 'Variable',
127
+ name: 'name'
128
+ }, context)).toBe('John');
129
+ });
130
+ });
131
+ describe('decodeHtmlEntities', () => {
132
+ describe('named entities', () => {
133
+ test('decodes   to non-breaking space', () => {
134
+ expect(decodeHtmlEntities('Hello World')).toBe('Hello\u00A0World');
135
+ });
136
+ test('decodes & to ampersand', () => {
137
+ expect(decodeHtmlEntities('A&B')).toBe('A&B');
138
+ });
139
+ test('decodes < and >', () => {
140
+ expect(decodeHtmlEntities('&lt;div&gt;')).toBe('<div>');
141
+ });
142
+ test('decodes &quot; and &apos;', () => {
143
+ expect(decodeHtmlEntities('&quot;hello&apos;')).toBe('"hello\'');
144
+ });
145
+ test('leaves unknown named entities as is', () => {
146
+ expect(decodeHtmlEntities('&unknown;')).toBe('&unknown;');
147
+ });
148
+ });
149
+ describe('decimal numeric entities', () => {
150
+ test('decodes &#160; to non-breaking space', () => {
151
+ expect(decodeHtmlEntities('Hello&#160;World')).toBe('Hello\u00A0World');
152
+ });
153
+ test('decodes &#60; and &#62;', () => {
154
+ expect(decodeHtmlEntities('&#60;div&#62;')).toBe('<div>');
155
+ });
156
+ });
157
+ describe('hex numeric entities', () => {
158
+ test('decodes &#x00A0; to non-breaking space', () => {
159
+ expect(decodeHtmlEntities('Hello&#x00A0;World')).toBe('Hello\u00A0World');
160
+ });
161
+ test('decodes &#xA0; (short form)', () => {
162
+ expect(decodeHtmlEntities('Hello&#xA0;World')).toBe('Hello\u00A0World');
163
+ });
164
+ test('decodes &#x3C; and &#x3E;', () => {
165
+ expect(decodeHtmlEntities('&#x3C;div&#x3E;')).toBe('<div>');
166
+ });
167
+ });
168
+ describe('mixed entities', () => {
169
+ test('decodes multiple different entity types', () => {
170
+ expect(decodeHtmlEntities('&lt;div&gt;&nbsp;&#60;span&#62;&#x00A0;')).toBe('<div>\u00A0<span>\u00A0');
171
+ });
172
+ test('returns text without entities unchanged', () => {
173
+ expect(decodeHtmlEntities('Hello World')).toBe('Hello World');
174
+ });
175
+ });
176
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,108 @@
1
+ import { stringToAst } from '../liquid/parser';
2
+ describe('stringToAst', () => {
3
+ describe('text parsing', () => {
4
+ test('parses plain text', () => {
5
+ const ast = stringToAst('Hello World');
6
+ expect(ast).toHaveLength(1);
7
+ expect(ast[0].type).toBe('Text');
8
+ expect(ast[0].content).toBe('Hello World');
9
+ });
10
+ test('parses empty string', () => {
11
+ const ast = stringToAst('');
12
+ expect(ast).toHaveLength(0);
13
+ });
14
+ });
15
+ describe('variable parsing', () => {
16
+ test('parses simple variable', () => {
17
+ const ast = stringToAst('{{ name }}');
18
+ expect(ast).toHaveLength(1);
19
+ const node = ast[0];
20
+ expect(node.type).toBe('Variable');
21
+ expect(node.value).toEqual({
22
+ type: 'Variable',
23
+ name: 'name'
24
+ });
25
+ });
26
+ test('parses variable with filter', () => {
27
+ const ast = stringToAst('{{ name | capitalize }}');
28
+ expect(ast).toHaveLength(1);
29
+ const node = ast[0];
30
+ expect(node.type).toBe('Variable');
31
+ expect(node.filters).toHaveLength(1);
32
+ expect(node.filters[0].name).toBe('capitalize');
33
+ });
34
+ test('parses variable with multiple filters', () => {
35
+ const ast = stringToAst('{{ name | capitalize | upperCase }}');
36
+ expect(ast).toHaveLength(1);
37
+ const node = ast[0];
38
+ expect(node.filters).toHaveLength(2);
39
+ expect(node.filters[0].name).toBe('capitalize');
40
+ expect(node.filters[1].name).toBe('upperCase');
41
+ });
42
+ test('parses variable mixed with text', () => {
43
+ const ast = stringToAst('Hello {{ name }}!');
44
+ expect(ast).toHaveLength(3);
45
+ expect(ast[0].content).toBe('Hello ');
46
+ expect(ast[1].type).toBe('Variable');
47
+ expect(ast[2].content).toBe('!');
48
+ });
49
+ });
50
+ describe('if control parsing', () => {
51
+ test('parses simple if block', () => {
52
+ const ast = stringToAst('{% if count == 1 %}one{% endif %}');
53
+ expect(ast).toHaveLength(1);
54
+ const node = ast[0];
55
+ expect(node.type).toBe('Control');
56
+ expect(node.name).toBe('if');
57
+ expect(node.condition[1]).toBe('==');
58
+ });
59
+ test('parses if-else block', () => {
60
+ const ast = stringToAst('{% if active == true %}yes{% else %}no{% endif %}');
61
+ expect(ast).toHaveLength(1);
62
+ const node = ast[0];
63
+ expect(node.children).toHaveLength(1);
64
+ expect(node.elseChildren).toHaveLength(1);
65
+ });
66
+ });
67
+ describe('for control parsing', () => {
68
+ test('parses for loop', () => {
69
+ const ast = stringToAst('{% for item in items %}{{ item }}{% endfor %}');
70
+ expect(ast).toHaveLength(1);
71
+ const node = ast[0];
72
+ expect(node.type).toBe('Control');
73
+ expect(node.name).toBe('for');
74
+ expect(node.varName).toBe('item');
75
+ });
76
+ });
77
+ describe('HTML tag parsing', () => {
78
+ test('parses self-closing br tag', () => {
79
+ const ast = stringToAst('line1<br/>line2');
80
+ expect(ast).toHaveLength(3);
81
+ expect(ast[0].content).toBe('line1');
82
+ expect(ast[1].type).toBe('Tag');
83
+ expect(ast[1].name).toBe('br');
84
+ expect(ast[2].content).toBe('line2');
85
+ });
86
+ test('parses void hr tag', () => {
87
+ const ast = stringToAst('before<hr>after');
88
+ expect(ast).toHaveLength(3);
89
+ expect(ast[1].name).toBe('hr');
90
+ });
91
+ test('parses img tag with attributes', () => {
92
+ const ast = stringToAst('<img src="test.png" alt="test">');
93
+ expect(ast).toHaveLength(1);
94
+ const node = ast[0];
95
+ expect(node.name).toBe('img');
96
+ expect(node.params).toHaveProperty('src');
97
+ expect(node.params).toHaveProperty('alt');
98
+ });
99
+ test('parses nested tags', () => {
100
+ const ast = stringToAst('<strong>bold text</strong>');
101
+ expect(ast).toHaveLength(1);
102
+ const node = ast[0];
103
+ expect(node.name).toBe('strong');
104
+ expect(node.children).toHaveLength(1);
105
+ expect(node.children[0].content).toBe('bold text');
106
+ });
107
+ });
108
+ });
@@ -0,0 +1 @@
1
+ import '@testing-library/jest-dom';
@@ -0,0 +1,255 @@
1
+ import '@testing-library/jest-dom';
2
+ import { render, screen } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { stringToAst } from '../liquid/parser';
5
+ import { renderToString, renderToReact } from '../liquid/render';
6
+ const renderString = (template, values = {}, language = 'en') => {
7
+ const ast = stringToAst(template);
8
+ const context = {
9
+ values,
10
+ language,
11
+ timezone: 'UTC'
12
+ };
13
+ return renderToString(ast, context);
14
+ };
15
+ const renderReactNodes = (template, values = {}, language = 'en') => {
16
+ const ast = stringToAst(template);
17
+ const context = {
18
+ values,
19
+ language,
20
+ timezone: 'UTC'
21
+ };
22
+ return renderToReact(ast, context);
23
+ };
24
+ describe('renderToString', () => {
25
+ describe('text rendering', () => {
26
+ test('renders plain text', () => {
27
+ expect(renderString('Hello World')).toBe('Hello World');
28
+ });
29
+ test('renders empty string', () => {
30
+ expect(renderString('')).toBe('');
31
+ });
32
+ });
33
+ describe('variable rendering', () => {
34
+ test('renders simple variable', () => {
35
+ expect(renderString('Hello {{ name }}', {
36
+ name: 'John'
37
+ })).toBe('Hello John');
38
+ });
39
+ test('renders multiple variables', () => {
40
+ expect(renderString('{{ greeting }} {{ name }}', {
41
+ greeting: 'Hi',
42
+ name: 'Jane'
43
+ })).toBe('Hi Jane');
44
+ });
45
+ test('renders variable with filter', () => {
46
+ expect(renderString('{{ name | upperCase }}', {
47
+ name: 'john'
48
+ })).toBe('JOHN');
49
+ });
50
+ test('renders variable with multiple filters', () => {
51
+ expect(renderString('{{ name | capitalize }}', {
52
+ name: 'john'
53
+ })).toBe('John');
54
+ });
55
+ });
56
+ describe('if control rendering', () => {
57
+ test('renders if block when true', () => {
58
+ expect(renderString('{% if active == true %}yes{% endif %}', {
59
+ active: true
60
+ })).toBe('yes');
61
+ });
62
+ test('renders nothing when false', () => {
63
+ expect(renderString('{% if active == true %}yes{% endif %}', {
64
+ active: false
65
+ })).toBe('');
66
+ });
67
+ test('renders else block when false', () => {
68
+ expect(renderString('{% if active == true %}yes{% else %}no{% endif %}', {
69
+ active: false
70
+ })).toBe('no');
71
+ });
72
+ test('handles numeric comparison', () => {
73
+ expect(renderString('{% if count > 0 %}positive{% endif %}', {
74
+ count: 5
75
+ })).toBe('positive');
76
+ });
77
+ test('handles != operator', () => {
78
+ expect(renderString('{% if status != "done" %}pending{% endif %}', {
79
+ status: 'active'
80
+ })).toBe('pending');
81
+ });
82
+ });
83
+ describe('for control rendering', () => {
84
+ test('renders for loop', () => {
85
+ expect(renderString('{% for item in items %}{{ item }},{% endfor %}', {
86
+ items: ['a', 'b', 'c']
87
+ })).toBe('a,b,c,');
88
+ });
89
+ test('renders empty for empty array', () => {
90
+ expect(renderString('{% for item in items %}{{ item }}{% endfor %}', {
91
+ items: []
92
+ })).toBe('');
93
+ });
94
+ });
95
+ describe('HTML tag rendering', () => {
96
+ test('renders self-closing tags', () => {
97
+ expect(renderString('line1<br/>line2')).toBe('line1<br/>line2');
98
+ });
99
+ test('renders void tags', () => {
100
+ expect(renderString('before<hr>after')).toBe('before<hr/>after');
101
+ });
102
+ test('renders tags with attributes', () => {
103
+ expect(renderString('<img src="test.png">')).toBe('<img src="test.png"/>');
104
+ });
105
+ test('renders nested tags', () => {
106
+ expect(renderString('<strong>bold</strong>')).toBe('<strong>bold</strong>');
107
+ });
108
+ });
109
+ describe('complex templates', () => {
110
+ test('renders template with variable in tag', () => {
111
+ const template = '{% if count > 0 %}<strong>{{ count }}</strong>{% endif %}';
112
+ expect(renderString(template, {
113
+ count: 5
114
+ })).toBe('<strong>5</strong>');
115
+ });
116
+ test('renders pluralized message', () => {
117
+ const template = '{{ count }} {{ count | pluralize: "item", "items" }}';
118
+ expect(renderString(template, {
119
+ count: 1
120
+ })).toBe('1 item');
121
+ expect(renderString(template, {
122
+ count: 5
123
+ })).toBe('5 items');
124
+ });
125
+ });
126
+ describe('HTML entities', () => {
127
+ test('decodes &nbsp; to non-breaking space', () => {
128
+ expect(renderString('Hello&nbsp;World')).toBe('Hello\u00A0World');
129
+ });
130
+ test('decodes numeric entities', () => {
131
+ expect(renderString('Hello&#160;World')).toBe('Hello\u00A0World');
132
+ });
133
+ test('decodes hex entities', () => {
134
+ expect(renderString('Hello&#x00A0;World')).toBe('Hello\u00A0World');
135
+ });
136
+ });
137
+ });
138
+ describe('renderToReact', () => {
139
+ describe('text rendering', () => {
140
+ test('renders plain text', () => {
141
+ const result = renderReactNodes('Hello World');
142
+ render(React.createElement(React.Fragment, null, result));
143
+ expect(screen.getByText('Hello World')).toBeInTheDocument();
144
+ });
145
+ });
146
+ describe('variable rendering', () => {
147
+ test('renders simple variable', () => {
148
+ const result = renderReactNodes('Hello {{ name }}', {
149
+ name: 'John'
150
+ });
151
+ render(React.createElement(React.Fragment, null, result));
152
+ expect(screen.getByText(/Hello/)).toBeInTheDocument();
153
+ expect(screen.getByText(/John/)).toBeInTheDocument();
154
+ });
155
+ test('renders variable with filter', () => {
156
+ const result = renderReactNodes('{{ name | upperCase }}', {
157
+ name: 'john'
158
+ });
159
+ render(React.createElement(React.Fragment, null, result));
160
+ expect(screen.getByText('JOHN')).toBeInTheDocument();
161
+ });
162
+ });
163
+ describe('HTML tag rendering', () => {
164
+ test('renders strong tag', () => {
165
+ const result = renderReactNodes('<strong>bold text</strong>');
166
+ render(React.createElement(React.Fragment, null, result));
167
+ const element = screen.getByText('bold text');
168
+ expect(element.tagName).toBe('STRONG');
169
+ });
170
+ test('renders br tag', () => {
171
+ const result = renderReactNodes('line1<br/>line2');
172
+ const {
173
+ container
174
+ } = render(React.createElement(React.Fragment, null, result));
175
+ expect(container.querySelector('br')).toBeInTheDocument();
176
+ });
177
+ test('renders img tag with attributes', () => {
178
+ const result = renderReactNodes('<img src="test.png" alt="test image">');
179
+ render(React.createElement(React.Fragment, null, result));
180
+ const img = screen.getByAltText('test image');
181
+ expect(img).toHaveAttribute('src', 'test.png');
182
+ });
183
+ test('renders anchor tag with href', () => {
184
+ const result = renderReactNodes('<a href="https://example.com">Link</a>');
185
+ render(React.createElement(React.Fragment, null, result));
186
+ const link = screen.getByText('Link');
187
+ expect(link.tagName).toBe('A');
188
+ expect(link).toHaveAttribute('href', 'https://example.com');
189
+ });
190
+ test('renders anchor tag with multiple attributes', () => {
191
+ const result = renderReactNodes('<a href="https://example.com" target="_blank" rel="noreferrer">External Link</a>');
192
+ render(React.createElement(React.Fragment, null, result));
193
+ const link = screen.getByText('External Link');
194
+ expect(link).toHaveAttribute('href', 'https://example.com');
195
+ expect(link).toHaveAttribute('target', '_blank');
196
+ expect(link).toHaveAttribute('rel', 'noreferrer');
197
+ });
198
+ test('renders anchor tag with variable in href', () => {
199
+ const result = renderReactNodes('<a href=linkUrl>Custom Link</a>', {
200
+ linkUrl: 'https://custom.com'
201
+ });
202
+ render(React.createElement(React.Fragment, null, result));
203
+ const link = screen.getByText('Custom Link');
204
+ expect(link).toHaveAttribute('href', 'https://custom.com');
205
+ });
206
+ });
207
+ describe('control structures', () => {
208
+ test('renders if block when true', () => {
209
+ const result = renderReactNodes('{% if active == true %}visible{% endif %}', {
210
+ active: true
211
+ });
212
+ render(React.createElement(React.Fragment, null, result));
213
+ expect(screen.getByText('visible')).toBeInTheDocument();
214
+ });
215
+ test('renders else block when false', () => {
216
+ const result = renderReactNodes('{% if active == true %}yes{% else %}no{% endif %}', {
217
+ active: false
218
+ });
219
+ render(React.createElement(React.Fragment, null, result));
220
+ expect(screen.getByText('no')).toBeInTheDocument();
221
+ });
222
+ test('renders for loop', () => {
223
+ const result = renderReactNodes('{% for item in items %}<span>{{ item }}</span>{% endfor %}', {
224
+ items: ['a', 'b', 'c']
225
+ });
226
+ render(React.createElement(React.Fragment, null, result));
227
+ expect(screen.getByText('a')).toBeInTheDocument();
228
+ expect(screen.getByText('b')).toBeInTheDocument();
229
+ expect(screen.getByText('c')).toBeInTheDocument();
230
+ });
231
+ });
232
+ describe('text tag with styles', () => {
233
+ test('renders text tag', () => {
234
+ const result = renderReactNodes('<text>styled text</text>');
235
+ render(React.createElement(React.Fragment, null, result));
236
+ expect(screen.getByText('styled text')).toBeInTheDocument();
237
+ });
238
+ });
239
+ describe('HTML entities', () => {
240
+ test('decodes &nbsp; in React rendering', () => {
241
+ const result = renderReactNodes('Hello&nbsp;World');
242
+ const {
243
+ container
244
+ } = render(React.createElement(React.Fragment, null, result));
245
+ expect(container.textContent).toBe('Hello\u00A0World');
246
+ });
247
+ test('decodes numeric entities in React rendering', () => {
248
+ const result = renderReactNodes('Hello&#160;World');
249
+ const {
250
+ container
251
+ } = render(React.createElement(React.Fragment, null, result));
252
+ expect(container.textContent).toBe('Hello\u00A0World');
253
+ });
254
+ });
255
+ });
@@ -40,5 +40,22 @@ export const filters = {
40
40
  }
41
41
  }
42
42
  return decimalPart ? `${integerPart},${decimalPart}` : integerPart;
43
+ },
44
+ formatDate: (value, args, context) => {
45
+ const date = new Date(value);
46
+ const format = args[0] ? ensureString(unpackScalarOrValue(args[0], context)) : 'medium';
47
+ return new Intl.DateTimeFormat(context.language, {
48
+ timeZone: context.timezone,
49
+ dateStyle: format
50
+ }).format(date);
51
+ },
52
+ formatDateTime: (value, args, context) => {
53
+ const date = new Date(value);
54
+ const format = args[0] ? ensureString(unpackScalarOrValue(args[0], context)) : 'medium';
55
+ return new Intl.DateTimeFormat(context.language, {
56
+ timeZone: context.timezone,
57
+ dateStyle: format,
58
+ timeStyle: 'short'
59
+ }).format(date);
43
60
  }
44
61
  };
@@ -7,3 +7,4 @@ export declare function addSpaceAroundOperators(expression: string): string;
7
7
  export declare function parseScalarOrVariable(value: string): ScalarOrVariable;
8
8
  export declare function stringifyScalarOrVariable(scalarOrVariable: ScalarOrVariable): string;
9
9
  export declare function unpackScalarOrValue(scalarOrVariable: ScalarOrVariable, context: Context): ScalarValue;
10
+ export declare function decodeHtmlEntities(text: string): string;
@@ -108,4 +108,21 @@ export function unpackScalarOrValue(scalarOrVariable, context) {
108
108
  return scalarOrVariable.value;
109
109
  }
110
110
  return context.values[scalarOrVariable.name];
111
+ }
112
+ const namedHtmlEntities = {
113
+ '&nbsp;': '\u00A0',
114
+ '&amp;': '&',
115
+ '&lt;': '<',
116
+ '&gt;': '>',
117
+ '&quot;': '"',
118
+ '&apos;': '\''
119
+ };
120
+ export function decodeHtmlEntities(text) {
121
+ return text
122
+ // Named entities: &nbsp; &amp; etc.
123
+ .replace(/&[a-zA-Z]+;/g, entity => namedHtmlEntities[entity] || entity)
124
+ // Decimal numeric: &#160;
125
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
126
+ // Hex numeric: &#x00A0;
127
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)));
111
128
  }
@@ -3,7 +3,7 @@ import styled from 'styled-components';
3
3
  import { Color } from '@pushwoosh/kit-constants';
4
4
  import { variants } from '@pushwoosh/kit-typography/map';
5
5
  import { filters } from './filters';
6
- import { stringifyScalarOrVariable, unpackScalarOrValue } from './helpers';
6
+ import { decodeHtmlEntities, stringifyScalarOrVariable, unpackScalarOrValue } from './helpers';
7
7
  import { ifOperatorFns, voidTags } from './parser';
8
8
  // Applies a filter to a value.
9
9
  // @param filterName The name of the filter to apply.
@@ -57,7 +57,7 @@ function renderNodesToArray(ast, context, renderTag) {
57
57
  for (let i = 0; i < ast.length; i++) {
58
58
  const node = ast[i];
59
59
  if (node.type === 'Text') {
60
- output.push(node.content);
60
+ output.push(decodeHtmlEntities(node.content));
61
61
  } else if (node.type === 'Variable') {
62
62
  const variableNode = node;
63
63
  const value = variableNode.filters.reduce((acc, filter) => applyFilter(filter.name, acc, filter.args, context), unpackScalarOrValue(variableNode.value, context));
@@ -69,4 +69,5 @@ export type IfOperator = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'contains' | 's
69
69
  export type Context = {
70
70
  values: any;
71
71
  language: string;
72
+ timezone: string;
72
73
  };
@@ -1,6 +1,7 @@
1
1
  import { type FC, type PropsWithChildren } from 'react';
2
2
  import { type LangMessages } from './types';
3
3
  type LangProviderProps = {
4
+ timezone: string;
4
5
  language: string;
5
6
  languages: Record<string, LangMessages<any>>;
6
7
  };
package/polyglot/react.js CHANGED
@@ -3,6 +3,7 @@ import { stringToAst } from './liquid/parser';
3
3
  import { renderToReact, renderToString } from './liquid/render';
4
4
  const LangContext = createContext(null);
5
5
  export const LangProvider = ({
6
+ timezone,
6
7
  language,
7
8
  languages,
8
9
  children
@@ -34,7 +35,8 @@ export const LangProvider = ({
34
35
  const ast = getAst(message);
35
36
  return renderToString(ast, {
36
37
  values,
37
- language
38
+ language,
39
+ timezone
38
40
  });
39
41
  },
40
42
  langReact: allProps => {
@@ -45,11 +47,12 @@ export const LangProvider = ({
45
47
  const ast = getAst(message);
46
48
  return renderToReact(ast, {
47
49
  values,
48
- language
50
+ language,
51
+ timezone
49
52
  });
50
53
  }
51
54
  };
52
- }, [languages, language]);
55
+ }, [languages, language, timezone]);
53
56
  return React.createElement(LangContext.Provider, {
54
57
  value: langFnPack
55
58
  }, children);