@subiz/widget-v4 4.1.218 → 4.1.222

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 (128) hide show
  1. package/assets/img/bar_ho_tro.svg +3 -0
  2. package/{dist/assets/img/bubble_default.6f6eec8.svg → assets/img/bubble_11.svg} +0 -1
  3. package/assets/less/form-page.less +58 -0
  4. package/assets/less/form.less +279 -3
  5. package/assets/less/widget.less +6 -0
  6. package/compiled_src/chatbox/ChatButton.js +22 -3
  7. package/compiled_src/chatbox/Widget.js +13 -18
  8. package/compiled_src/form/Form.js +270 -71
  9. package/compiled_src/form/FormPage.js +17 -4
  10. package/compiled_src/form/form-fields.js +244 -0
  11. package/compiled_src/form/index.js +2 -0
  12. package/compiled_src/form/rating-stars.js +48 -88
  13. package/compiled_src/plugin.js +10 -0
  14. package/compiled_src/store/api.js +5 -7
  15. package/compiled_src/store/api_fake.js +3 -1
  16. package/compiled_src/store/localize.js +33 -0
  17. package/compiled_src/store/store.js +57 -1
  18. package/compiled_src/store/widget-settings.js +67 -0
  19. package/compiled_src/util/common.js +1 -1
  20. package/package.json +3 -3
  21. package/src/chatbox/ChatButton.js +25 -4
  22. package/src/chatbox/Widget.js +21 -21
  23. package/src/form/Form.js +335 -97
  24. package/src/form/FormPage.js +21 -8
  25. package/src/form/form-fields.js +224 -0
  26. package/src/form/index.js +6 -1
  27. package/src/form/rating-stars.js +48 -64
  28. package/src/plugin.js +10 -0
  29. package/src/store/api.js +9 -7
  30. package/src/store/api_fake.js +3 -1
  31. package/src/store/localize.js +30 -0
  32. package/src/store/store.js +64 -1
  33. package/src/store/widget-settings.js +46 -0
  34. package/src/util/common.js +1 -1
  35. package/test.cjs +3 -0
  36. package/webpack.dev.js +2 -0
  37. package/dist/127.js +0 -273
  38. package/dist/132.js +0 -2817
  39. package/dist/185.js +0 -1917
  40. package/dist/1a12ed33347.127.js +0 -1
  41. package/dist/1a12ed33347.132.js +0 -1
  42. package/dist/1a12ed33347.185.js +0 -1
  43. package/dist/1a12ed33347.27.js +0 -1
  44. package/dist/1a12ed33347.278.js +0 -1
  45. package/dist/1a12ed33347.339.js +0 -1
  46. package/dist/1a12ed33347.459.js +0 -1
  47. package/dist/1a12ed33347.490.js +0 -1
  48. package/dist/1a12ed33347.app.js +0 -1
  49. package/dist/1a12ed33347.chat.html +0 -1
  50. package/dist/1a12ed33347.chat.js +0 -1
  51. package/dist/1a12ed33347.form.html +0 -1
  52. package/dist/1a12ed33347.form.js +0 -1
  53. package/dist/1a12ed33347.index.html +0 -72
  54. package/dist/1a12ed33347.library.html +0 -72
  55. package/dist/1a12ed33347.library.js +0 -1
  56. package/dist/1a12ed33347.preview.html +0 -759
  57. package/dist/1a12ed33347.preview.js +0 -1
  58. package/dist/1a12ed33347.webview.js +0 -1
  59. package/dist/27.js +0 -37
  60. package/dist/459.js +0 -37
  61. package/dist/490.js +0 -305
  62. package/dist/HASH +0 -1
  63. package/dist/assets/img/Facebook.64607ec.png +0 -0
  64. package/dist/assets/img/Zalo.6845707.png +0 -0
  65. package/dist/assets/img/award1a.1a58e10.png +0 -0
  66. package/dist/assets/img/award3a.3b74423.png +0 -0
  67. package/dist/assets/img/award4a.ed6ccd4.png +0 -0
  68. package/dist/assets/img/award5.39eb7ae.png +0 -0
  69. package/dist/assets/img/caret.998eb8f.svg +0 -1
  70. package/dist/assets/img/convos_empty.2353007.svg +0 -72
  71. package/dist/assets/img/default.06e6ee0.jpg +0 -0
  72. package/dist/assets/img/default_avatar.e449b04.png +0 -0
  73. package/dist/assets/img/default_logo.dd13a2b.svg +0 -5
  74. package/dist/assets/img/done_gray.d7e8203.svg +0 -4
  75. package/dist/assets/img/done_green.b817fd4.svg +0 -4
  76. package/dist/assets/img/dot_chat.335fd8a.svg +0 -3
  77. package/dist/assets/img/emoji/messenger/sub_23.529_58.824.d5faf14.png +0 -0
  78. package/dist/assets/img/emoji/messenger/sub_23.529_60.784.1420241.png +0 -0
  79. package/dist/assets/img/emoji/messenger/sub_25.490_0.000.1e6b2c0.png +0 -0
  80. package/dist/assets/img/emoji/messenger/sub_25.490_45.098.3dfbc08.png +0 -0
  81. package/dist/assets/img/emoji/messenger/sub_25.490_5.882.4b49e94.png +0 -0
  82. package/dist/assets/img/emoji/messenger/sub_27.451_72.549.7114f58.png +0 -0
  83. package/dist/assets/img/emoji/messenger/sub_27.451_84.314.231e2b7.png +0 -0
  84. package/dist/assets/img/emoji/messenger/sub_27.451_96.078.ddbb008.png +0 -0
  85. package/dist/assets/img/emoji/messenger/sub_29.412_5.882.61f3359.png +0 -0
  86. package/dist/assets/img/emoji/messenger/sub_58.824_100.000.6df72bc.png +0 -0
  87. package/dist/assets/img/emoji/messenger/sub_58.824_45.098.3d7a7ad.png +0 -0
  88. package/dist/assets/img/emoji/messenger/sub_58.824_47.059.0f4d6e2.png +0 -0
  89. package/dist/assets/img/emoji/messenger/sub_58.824_54.902.65724c1.png +0 -0
  90. package/dist/assets/img/emoji/messenger/sub_58.824_64.706.9da3cba.png +0 -0
  91. package/dist/assets/img/emoji/messenger/sub_58.824_72.549.edca5cf.png +0 -0
  92. package/dist/assets/img/emoji/messenger/sub_58.824_78.431.f910f2a.png +0 -0
  93. package/dist/assets/img/emoji/messenger/sub_58.824_88.235.def4e71.png +0 -0
  94. package/dist/assets/img/emoji/messenger/sub_60.784_11.765.49c03d0.png +0 -0
  95. package/dist/assets/img/emoji/messenger/sub_60.784_27.451.46b54e5.png +0 -0
  96. package/dist/assets/img/emoji/messenger/sub_60.784_29.412.e36d284.png +0 -0
  97. package/dist/assets/img/emoji/messenger/sub_60.784_3.922.bb191b0.png +0 -0
  98. package/dist/assets/img/emoji/messenger/sub_60.784_35.294.af7b927.png +0 -0
  99. package/dist/assets/img/emoji/messenger/sub_60.784_7.843.98f2a99.png +0 -0
  100. package/dist/assets/img/empty.29bed2d.svg +0 -23
  101. package/dist/assets/img/empty_1.0b98af0.svg +0 -11
  102. package/dist/assets/img/examplte_logo.f86535f.svg +0 -1
  103. package/dist/assets/img/external-link.90577cc.svg +0 -1
  104. package/dist/assets/img/fb_inverse_circle.36c2cfc.png +0 -0
  105. package/dist/assets/img/ghn.070f8e3.svg +0 -17
  106. package/dist/assets/img/ghtk.326eac1.svg +0 -18
  107. package/dist/assets/img/image_default.be0698b.png +0 -0
  108. package/dist/assets/img/info.ebce7ea.svg +0 -1
  109. package/dist/assets/img/loader.37000a7.svg +0 -1
  110. package/dist/assets/img/loading_static.a27f0ac.svg +0 -10
  111. package/dist/assets/img/main-service1.d536cc2.png +0 -0
  112. package/dist/assets/img/main-service2.9b99549.png +0 -0
  113. package/dist/assets/img/not_found_search.2ddb8d1.png +0 -0
  114. package/dist/assets/img/package.8d37d0f.svg +0 -5
  115. package/dist/assets/img/package_fade.763c001.svg +0 -1
  116. package/dist/assets/img/rating--average.1ea492c.svg +0 -12
  117. package/dist/assets/img/rating--great.e3d712e.svg +0 -16
  118. package/dist/assets/img/rating--poor.2938a25.svg +0 -12
  119. package/dist/assets/img/search.f9705ff.svg +0 -1
  120. package/dist/assets/img/send.6db52a9.svg +0 -3
  121. package/dist/assets/img/shop.f7dc7d8.svg +0 -1
  122. package/dist/assets/img/shop_faded.df7845a.svg +0 -1
  123. package/dist/assets/img/stars_v2.79cf728.png +0 -0
  124. package/dist/assets/img/subiz-mono.8fbc067.svg +0 -3
  125. package/dist/assets/img/x.26fdfa3.svg +0 -5
  126. package/dist/malicious-app.js +0 -17
  127. package/dist/screenshoot.html +0 -7
  128. package/dist/screenshoot.js +0 -14454
package/src/form/Form.js CHANGED
@@ -3,22 +3,47 @@ import {h, createRef, Fragment} from 'preact'
3
3
  import sb from '../util/index.js'
4
4
  import Block from '../chatbox/block.js'
5
5
  import RatingStars from './rating-stars.js'
6
+ import {
7
+ OTHER_OPTION_VALUE,
8
+ translate,
9
+ isChips,
10
+ getPlaceholder,
11
+ getDescription,
12
+ toStoredValue,
13
+ sameValue,
14
+ getRatingConfig,
15
+ getScaleRange,
16
+ optionLabel,
17
+ isEmptyValue,
18
+ RatingFaces,
19
+ LinearScale,
20
+ } from './form-fields.js'
6
21
 
7
22
  export default class Form2 extends Com {
8
23
  state = {
9
24
  errorFields: {},
10
25
  submiting: false,
26
+ // "Other…" option (allow_other_option) of radio/checkbox/dropdown, by field key
27
+ otherSelected: {},
28
+ otherText: {},
11
29
  }
12
30
 
13
31
  constructor(props) {
14
32
  super(props)
33
+ }
15
34
 
16
- let components = sb.get(this.props.formSetting, 'components') || []
17
- sb.each(components, (com) => {
18
- if (com.type === 'input' && com.input_type !== 'checkbox' && com.input_type !== 'radio') {
19
- this[`${com.key}_input`] = createRef()
20
- }
21
- })
35
+ // refs are created lazily: formSetting may arrive after the first render (chat attachment)
36
+ getRef = (name) => {
37
+ if (!this[name]) this[name] = createRef()
38
+ return this[name]
39
+ }
40
+
41
+ tr = (key) => translate(this.props.t, key)
42
+
43
+ getValue = (key, def) => {
44
+ let val = sb.find(sb.get(this.props.submission, 'values'), (f) => f.key === key) || {}
45
+ val = val.value || def || '""'
46
+ return sb.parseJSON(val)
22
47
  }
23
48
 
24
49
  renderFormComponent = (com, idx) => {
@@ -46,33 +71,79 @@ export default class Form2 extends Com {
46
71
  )
47
72
  }
48
73
 
74
+ // readable answer for submitted forms: labels instead of option codes, icons for rating
75
+ renderSubmittedValue = (com) => {
76
+ let empty = <span class='text-muted text-italic'>{this.props.t('no_info_input')}</span>
77
+ let val = this.getValue(com.key)
78
+ if (com.input_type === 'rating') {
79
+ let cfg = getRatingConfig(com)
80
+ let idx = sb.size(cfg.levels) ? cfg.levels.findIndex((l) => sameValue(val, l.value)) : -1
81
+ if (idx < 0) return empty
82
+ if (cfg.kind === 'face') return <RatingFaces readonly levels={[cfg.levels[idx]]} value={val} />
83
+ return (
84
+ <div class='form-submitted-rating'>
85
+ <RatingStars readonly count={cfg.levels.length} value={idx + 1} />
86
+ <span class='text-muted'>
87
+ {idx + 1}/{cfg.levels.length}
88
+ </span>
89
+ </div>
90
+ )
91
+ }
92
+ if (com.input_type === 'linear_scale') {
93
+ if (isEmptyValue(val)) return empty
94
+ let {min, max} = getScaleRange(com)
95
+ let text = `${val}/${max}`
96
+ if (sameValue(val, min) && com.scale_min_label) text += ` · ${com.scale_min_label}`
97
+ if (sameValue(val, max) && com.scale_max_label) text += ` · ${com.scale_max_label}`
98
+ return <div>{text}</div>
99
+ }
100
+ if (['radio', 'checkbox', 'dropdown', 'select'].includes(com.input_type)) {
101
+ let vals = Array.isArray(val) ? val : isEmptyValue(val) ? [] : [val]
102
+ let labels = sb.map(vals, (v) => optionLabel(com, v))
103
+ if (labels.length === 0) return empty
104
+ if (com.input_type === 'checkbox' && isChips(com)) {
105
+ return (
106
+ <div class='form-chips readonly'>
107
+ {sb.map(labels, (label) => (
108
+ <span class='form-chip selected'>{label}</span>
109
+ ))}
110
+ </div>
111
+ )
112
+ }
113
+ return <div>{labels.join(', ')}</div>
114
+ }
115
+ if (Array.isArray(val)) val = val.join(', ')
116
+ return <div>{val || empty}</div>
117
+ }
118
+
49
119
  renderField = (com, idx) => {
50
120
  let errorMsg = this.state.errorFields[com.key]
51
121
  if (this.props.isSubmited) {
52
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
53
- val = val.value || '""'
54
- val = sb.parseJSON(val)
55
- if (Array.isArray(val)) val = val.join(', ')
56
-
57
122
  return (
58
123
  <div class='form-component submitted' key={com.key}>
59
124
  <label class='label'>{com.label}</label>
60
- <div>{val || <span class='text-muted text-italic'>{this.props.t('no_info_input')}</span>}</div>
125
+ {this.renderSubmittedValue(com)}
61
126
  </div>
62
127
  )
63
128
  }
129
+ let desc = getDescription(com)
130
+ let cls = 'form-component'
131
+ if (desc) cls += ' has-description'
64
132
  return (
65
- <div class='form-component' key={com.key}>
133
+ <div class={cls} key={com.key}>
66
134
  <label class='label'>
67
135
  {com.label} {com.is_required && <span class='red'>*</span>}
68
136
  </label>
137
+ {desc && <div class='form-field-description'>{desc}</div>}
69
138
  {com.input_type === 'text' && this.renderFieldTextInput(com, idx)}
70
139
  {com.input_type === 'textarea' && this.renderFieldTextareaInput(com, idx)}
71
140
  {com.input_type === 'date' && this.renderFieldDateInput(com, idx)}
72
141
  {com.input_type === 'radio' && this.renderFieldRadioInput(com, idx)}
73
- {com.input_type === 'checkbox' && this.renderFieldCheckboxInput(com, idx)}
142
+ {com.input_type === 'checkbox' &&
143
+ (isChips(com) ? this.renderFieldChipsInput(com, idx) : this.renderFieldCheckboxInput(com, idx))}
74
144
  {(com.input_type === 'dropdown' || com.input_type === 'select') && this.renderFieldDropdownInput(com, idx)}
75
145
  {com.input_type === 'rating' && this.renderFieldRatingInput(com, idx)}
146
+ {com.input_type === 'linear_scale' && this.renderFieldLinearScaleInput(com, idx)}
76
147
  {errorMsg && <div class='form-control--valid-input--error'>{errorMsg}</div>}
77
148
  </div>
78
149
  )
@@ -81,15 +152,13 @@ export default class Form2 extends Com {
81
152
  renderFieldTextInput = (com, idx) => {
82
153
  let cls = 'form-control'
83
154
  if (this.state.errorFields[com.key]) cls = 'form-control input--error'
84
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
85
- val = val.value || '""'
86
- val = sb.parseJSON(val)
155
+ let val = this.getValue(com.key)
87
156
  return (
88
157
  <input
89
- ref={this[`${com.key}_input`]}
158
+ ref={this.getRef(`${com.key}_input`)}
90
159
  type='text'
91
160
  class={cls}
92
- placeholder={com.placeholder}
161
+ placeholder={getPlaceholder(com)}
93
162
  value={val}
94
163
  onInput={(e) => {
95
164
  this.onChangeValue(com.key, e.target.value)
@@ -100,14 +169,12 @@ export default class Form2 extends Com {
100
169
  }
101
170
 
102
171
  renderFieldDateInput = (com, idx) => {
103
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
104
- val = val.value || '""'
105
- val = sb.parseJSON(val)
172
+ let val = this.getValue(com.key)
106
173
  let cls = 'form-control'
107
174
  if (this.state.errorFields[com.key]) cls = 'form-control input--error'
108
175
  return (
109
176
  <input
110
- ref={this[`${com.key}_input`]}
177
+ ref={this.getRef(`${com.key}_input`)}
111
178
  class={cls}
112
179
  type='date'
113
180
  value={val}
@@ -119,110 +186,236 @@ export default class Form2 extends Com {
119
186
  )
120
187
  }
121
188
 
189
+ // free text box shown when the "Other…" option is selected
190
+ renderOtherTextInput = (com) => {
191
+ if (!com.allow_other_option || !this.state.otherSelected[com.key]) return null
192
+ let cls = 'form-control form-other-input'
193
+ if (this.state.errorFields[com.key]) cls += ' input--error'
194
+ return (
195
+ <input
196
+ ref={this.getRef(`${com.key}_other_input`)}
197
+ type='text'
198
+ class={cls}
199
+ placeholder={this.tr('form_other_option_placeholder')}
200
+ value={this.state.otherText[com.key] || ''}
201
+ onInput={(e) => this.setOtherText(com, e.target.value)}
202
+ />
203
+ )
204
+ }
205
+
122
206
  renderFieldRadioInput = (com, idx) => {
123
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
124
- val = val.value || '""'
125
- val = sb.parseJSON(val)
207
+ let val = this.getValue(com.key)
208
+ let otherOn = !!this.state.otherSelected[com.key]
126
209
  let wrappercls = 'form-radios-wrapper'
127
- if (sb.size(com.input_options) >= 3 && this.props.mode !== 'attachment') wrappercls += ' horizontal'
210
+ let count = sb.size(com.input_options) + (com.allow_other_option ? 1 : 0)
211
+ if (count >= 3 && this.props.mode !== 'attachment') wrappercls += ' horizontal'
128
212
  return (
129
- <div class={wrappercls}>
130
- {sb.map(com.input_options, (option, oidx) => {
131
- let id = com.key + '_' + oidx
132
- return (
213
+ <Fragment>
214
+ <div class={wrappercls}>
215
+ {sb.map(com.input_options, (option, oidx) => {
216
+ let id = com.key + '_' + oidx
217
+ return (
218
+ <div class='radio'>
219
+ <input
220
+ type='radio'
221
+ name={com.key}
222
+ id={id}
223
+ checked={!otherOn && option.value === val}
224
+ onChange={() => this.toggleRadioField(com.key, option.value)}
225
+ />
226
+ <label class='radio--label' for={id}>
227
+ {option.label}
228
+ </label>
229
+ <div class='radio--check' />
230
+ </div>
231
+ )
232
+ })}
233
+ {com.allow_other_option && (
133
234
  <div class='radio'>
134
235
  <input
135
236
  type='radio'
136
237
  name={com.key}
137
- id={id}
138
- checked={option.value === val}
139
- onChange={() => this.toggleRadioField(com.key, option.value)}
238
+ id={com.key + '_other'}
239
+ checked={otherOn}
240
+ onChange={() => this.setOtherSelected(com, true)}
140
241
  />
141
- <label class='radio--label' for={id}>
142
- {option.label}
242
+ <label class='radio--label' for={com.key + '_other'}>
243
+ {this.tr('form_other_option')}
143
244
  </label>
144
245
  <div class='radio--check' />
145
246
  </div>
146
- )
147
- })}
148
- </div>
247
+ )}
248
+ </div>
249
+ {this.renderOtherTextInput(com)}
250
+ </Fragment>
149
251
  )
150
252
  }
151
253
 
152
254
  renderFieldCheckboxInput = (com, idx) => {
153
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
154
- val = val.value || '[]'
155
- val = sb.parseJSON(val)
255
+ let val = this.getValue(com.key, '[]')
256
+ if (!Array.isArray(val)) val = []
257
+ let otherOn = !!this.state.otherSelected[com.key]
156
258
  let wrappercls = 'form-checkboxes-wrapper'
157
- if (sb.size(com.input_options) >= 3 && this.props.mode !== 'attachment') wrappercls += ' horizontal'
259
+ let count = sb.size(com.input_options) + (com.allow_other_option ? 1 : 0)
260
+ if (count >= 3 && this.props.mode !== 'attachment') wrappercls += ' horizontal'
158
261
  return (
159
- <div class={wrappercls}>
160
- {sb.map(com.input_options, (option, oidx) => {
161
- let id = com.key + '_' + oidx
162
- return (
262
+ <Fragment>
263
+ <div class={wrappercls}>
264
+ {sb.map(com.input_options, (option, oidx) => {
265
+ let id = com.key + '_' + oidx
266
+ return (
267
+ <div class='checkbox'>
268
+ <input
269
+ type='checkbox'
270
+ name={com.key}
271
+ id={id}
272
+ checked={val.includes(option.value)}
273
+ onChange={() => this.toggleCheckboxField(com.key, option.value)}
274
+ />
275
+ <label class='checkbox--label' for={id}>
276
+ {option.label}
277
+ </label>
278
+ <div class='checkbox--check' />
279
+ </div>
280
+ )
281
+ })}
282
+ {com.allow_other_option && (
163
283
  <div class='checkbox'>
164
284
  <input
165
285
  type='checkbox'
166
286
  name={com.key}
167
- id={id}
168
- checked={val.includes(option.value)}
169
- onChange={() => this.toggleCheckboxField(com.key, option.value)}
287
+ id={com.key + '_other'}
288
+ checked={otherOn}
289
+ onChange={() => this.setOtherSelected(com, !otherOn)}
170
290
  />
171
- <label class='checkbox--label' for={id}>
172
- {option.label}
291
+ <label class='checkbox--label' for={com.key + '_other'}>
292
+ {this.tr('form_other_option')}
173
293
  </label>
174
294
  <div class='checkbox--check' />
175
295
  </div>
176
- )
177
- })}
178
- </div>
296
+ )}
297
+ </div>
298
+ {this.renderOtherTextInput(com)}
299
+ </Fragment>
300
+ )
301
+ }
302
+
303
+ renderFieldChipsInput = (com, idx) => {
304
+ let val = this.getValue(com.key, '[]')
305
+ if (!Array.isArray(val)) val = []
306
+ let otherOn = !!this.state.otherSelected[com.key]
307
+ return (
308
+ <Fragment>
309
+ <div class='form-chips' role='group'>
310
+ {sb.map(com.input_options, (option) => {
311
+ let selected = val.includes(option.value)
312
+ return (
313
+ <button
314
+ type='button'
315
+ class={'form-chip' + (selected ? ' selected' : '')}
316
+ aria-pressed={selected ? 'true' : 'false'}
317
+ onClick={() => this.toggleCheckboxField(com.key, option.value)}
318
+ >
319
+ {option.label || option.value}
320
+ </button>
321
+ )
322
+ })}
323
+ {com.allow_other_option && (
324
+ <button
325
+ type='button'
326
+ class={'form-chip form-chip-other' + (otherOn ? ' selected' : '')}
327
+ aria-pressed={otherOn ? 'true' : 'false'}
328
+ onClick={() => this.setOtherSelected(com, !otherOn)}
329
+ >
330
+ {this.tr('form_other_option')}
331
+ </button>
332
+ )}
333
+ </div>
334
+ {this.renderOtherTextInput(com)}
335
+ </Fragment>
179
336
  )
180
337
  }
181
338
 
182
339
  renderFieldDropdownInput = (com, idx) => {
183
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
184
- val = val.value || '""'
185
- val = sb.parseJSON(val)
340
+ let val = this.getValue(com.key)
341
+ let otherOn = !!this.state.otherSelected[com.key]
186
342
  let cls = 'form-control'
187
- if (this.state.errorFields[com.key]) cls += ' input--error'
188
- if (!val) cls += ' form-control--empty'
343
+ if (this.state.errorFields[com.key] && !otherOn) cls += ' input--error'
344
+ if (!val && !otherOn) cls += ' form-control--empty'
189
345
  return (
190
- <select
191
- class={cls}
192
- ref={this[`${com.key}_input`]}
193
- onChange={(e) => {
194
- this.onChangeValue(com.key, e.target.value)
195
- this.clearError(com.key)
196
- }}
197
- >
198
- <option value=''>-- {this.props.t('no_select')} --</option>
199
- {sb.map(com.input_options, (option) => {
200
- return <option value={option.value}>{option.label}</option>
201
- })}
202
- </select>
346
+ <Fragment>
347
+ <select
348
+ class={cls}
349
+ ref={this.getRef(`${com.key}_input`)}
350
+ value={otherOn ? OTHER_OPTION_VALUE : val || ''}
351
+ onChange={(e) => {
352
+ if (e.target.value === OTHER_OPTION_VALUE) {
353
+ this.setOtherSelected(com, true)
354
+ return
355
+ }
356
+ if (otherOn) this.setOtherSelected(com, false, e.target.value)
357
+ else this.onChangeValue(com.key, e.target.value)
358
+ this.clearError(com.key)
359
+ }}
360
+ >
361
+ <option value=''>-- {this.props.t('no_select')} --</option>
362
+ {sb.map(com.input_options, (option) => {
363
+ return <option value={option.value}>{option.label}</option>
364
+ })}
365
+ {com.allow_other_option && <option value={OTHER_OPTION_VALUE}>{this.tr('form_other_option')}</option>}
366
+ </select>
367
+ {this.renderOtherTextInput(com)}
368
+ </Fragment>
203
369
  )
204
370
  }
205
371
 
206
372
  renderFieldTextareaInput = (com, idx) => {
207
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
208
- val = val.value || '""'
209
- val = sb.parseJSON(val)
373
+ let cls = 'form-control'
374
+ if (this.state.errorFields[com.key]) cls += ' input--error'
210
375
  return (
211
376
  <textarea
212
377
  onInput={(e) => {
213
378
  this.onChangeValue(com.key, e.target.value)
214
379
  this.clearError(com.key)
215
380
  }}
216
- ref={this[`${com.key}_input`]}
381
+ ref={this.getRef(`${com.key}_input`)}
217
382
  rows='3'
218
- class='form-control'
219
- placeholder={com.placeholder}
383
+ class={cls}
384
+ placeholder={getPlaceholder(com)}
220
385
  />
221
386
  )
222
387
  }
223
388
 
224
389
  renderFieldRatingInput = (com, idx) => {
225
- return <RatingStars onChange={(v) => this.onChangeValue(com.key, v)} />
390
+ let cfg = getRatingConfig(com)
391
+ let val = this.getValue(com.key)
392
+ let onChange = (v) => {
393
+ this.onChangeValue(com.key, v)
394
+ this.clearError(com.key)
395
+ }
396
+ if (cfg.kind === 'face') return <RatingFaces levels={cfg.levels} value={val} onChange={onChange} />
397
+ let selectedIdx = cfg.levels.findIndex((l) => sameValue(val, l.value))
398
+ return (
399
+ <RatingStars
400
+ count={cfg.levels.length}
401
+ value={selectedIdx + 1}
402
+ onChange={(n) => onChange(n > 0 ? toStoredValue(cfg.levels[n - 1].value) : '')}
403
+ />
404
+ )
405
+ }
406
+
407
+ renderFieldLinearScaleInput = (com, idx) => {
408
+ return (
409
+ <LinearScale
410
+ com={com}
411
+ numberedLabels={this.props.mode === 'attachment'}
412
+ value={this.getValue(com.key)}
413
+ onChange={(v) => {
414
+ this.onChangeValue(com.key, v)
415
+ this.clearError(com.key)
416
+ }}
417
+ />
418
+ )
226
419
  }
227
420
 
228
421
  onSubmit = (e) => {
@@ -238,20 +431,19 @@ export default class Form2 extends Com {
238
431
  let components = sb.get(this.props.formSetting, 'components') || []
239
432
  sb.each(components, (com) => {
240
433
  if (com.type === 'input') {
241
- let val = sb.find(this.props.submission.values, (f) => f.key === com.key) || {}
242
- val = val.value || '""'
243
- val = sb.parseJSON(val)
434
+ let val = this.getValue(com.key)
244
435
  if (com.is_required) {
245
- let isEmpty = false
246
- if (typeof val === 'string' && !val.trim()) isEmpty = true
247
- if (Array.isArray(val) && val.length === 0) isEmpty = true
436
+ let isEmpty = isEmptyValue(val)
437
+ // legacy rating stored 0 when the selected star was clicked again
438
+ if (com.input_type === 'rating' && val === 0) isEmpty = true
248
439
  if (isEmpty) {
249
440
  result = false
250
441
  errors[com.key] = this.props.t('cannot_be_empty')
251
- ref = `${com.key}_input`
442
+ if (!ref) ref = this.state.otherSelected[com.key] ? `${com.key}_other_input` : `${com.key}_input`
252
443
  }
253
444
  }
254
- if (com.validation && !Array.isArray(val)) {
445
+ let isChoice = ['rating', 'linear_scale', 'radio', 'checkbox', 'dropdown', 'select'].includes(com.input_type)
446
+ if (com.validation && !Array.isArray(val) && !isChoice) {
255
447
  let isInvalid = false
256
448
  let validationText = ''
257
449
  if (com.validation === 'email' && !sb.isEmail(val)) {
@@ -273,7 +465,7 @@ export default class Form2 extends Com {
273
465
  if (isInvalid) {
274
466
  result = false
275
467
  errors[com.key] = this.props.t('format_is_invalid') + ' ' + validationText
276
- ref = `${com.key}_input`
468
+ if (!ref) ref = `${com.key}_input`
277
469
  }
278
470
  }
279
471
  }
@@ -287,13 +479,25 @@ export default class Form2 extends Com {
287
479
 
288
480
  toggleRadioField(key, v) {
289
481
  this.clearError(key)
482
+ if (this.state.otherSelected[key]) {
483
+ this.setState({otherSelected: {...this.state.otherSelected, [key]: false}})
484
+ }
290
485
  this.onChangeValue(key, v)
291
486
  }
292
487
 
488
+ getComponent = (key) => sb.find(sb.get(this.props.formSetting, 'components') || [], (c) => c.key === key)
489
+
490
+ // checkbox value = selected option values + the "Other" free text (if any), as a JSON array
491
+ commitCheckboxValue = (com, optionValues, otherOn, otherText) => {
492
+ let values = sb.filter(optionValues, (v) => sb.find(com.input_options, (o) => o.value === v))
493
+ let text = (otherText || '').trim()
494
+ if (otherOn && text) values.push(text)
495
+ this.onChangeValue(com.key, values)
496
+ }
497
+
293
498
  toggleCheckboxField(key, v) {
294
- let currentValues = sb.find(this.props.submission.values, (f) => f.key === key) || {}
295
- currentValues = currentValues.value || '[]'
296
- currentValues = sb.parseJSON(currentValues)
499
+ let currentValues = this.getValue(key, '[]')
500
+ if (!Array.isArray(currentValues)) currentValues = []
297
501
 
298
502
  if (currentValues.includes(v)) {
299
503
  currentValues = currentValues.filter((s) => s !== v)
@@ -301,9 +505,45 @@ export default class Form2 extends Com {
301
505
  currentValues.push(v)
302
506
  }
303
507
  this.clearError(key)
508
+ let com = this.getComponent(key)
509
+ if (com && com.allow_other_option) {
510
+ this.commitCheckboxValue(com, currentValues, this.state.otherSelected[key], this.state.otherText[key])
511
+ return
512
+ }
304
513
  this.onChangeValue(key, currentValues)
305
514
  }
306
515
 
516
+ // toggles the "Other…" option. For radio/dropdown, `nextValue` is the value
517
+ // to store when switching back to a regular option
518
+ setOtherSelected = (com, on, nextValue) => {
519
+ let key = com.key
520
+ let otherSelected = {...this.state.otherSelected, [key]: on}
521
+ let text = this.state.otherText[key] || ''
522
+ this.clearError(key)
523
+ this.setState({otherSelected}, () => {
524
+ let $input = this.getRef(`${key}_other_input`).current
525
+ if (on && $input) {
526
+ $input.focus()
527
+ }
528
+ })
529
+ if (com.input_type === 'checkbox') {
530
+ this.commitCheckboxValue(com, this.getValue(key, '[]') || [], on, text)
531
+ return
532
+ }
533
+ this.onChangeValue(key, on ? text.trim() : nextValue || '')
534
+ }
535
+
536
+ setOtherText = (com, text) => {
537
+ let key = com.key
538
+ this.setState({otherText: {...this.state.otherText, [key]: text}})
539
+ this.clearError(key)
540
+ if (com.input_type === 'checkbox') {
541
+ this.commitCheckboxValue(com, this.getValue(key, '[]') || [], true, text)
542
+ return
543
+ }
544
+ this.onChangeValue(key, text.trim())
545
+ }
546
+
307
547
  onChangeValue = (key, v) => {
308
548
  let formValues = sb.get(this.props.submission, 'values') || []
309
549
  let f = sb.find(formValues, (field) => field.key === key)
@@ -328,9 +568,9 @@ export default class Form2 extends Com {
328
568
  if (this.props.mode === 'attachment') {
329
569
  if (title.trim() || desc.trim()) {
330
570
  return (
331
- <div class='form-component form-component-block'>
332
- {title && <div>{title}</div>}
333
- {desc && <div class='text-sm text-muted'>{desc}</div>}
571
+ <div class='form-component form-component-block form-attachment-header'>
572
+ {title && <div class='form-attachment-title'>{title}</div>}
573
+ {desc && <div class='form-attachment-desc text-sm text-muted'>{desc}</div>}
334
574
  </div>
335
575
  )
336
576
  }
@@ -368,8 +608,6 @@ export default class Form2 extends Com {
368
608
 
369
609
  render() {
370
610
  let components = sb.get(this.props.formSetting, 'components') || []
371
- let title = sb.get(this.props.formSetting, 'title') || ''
372
- let desc = sb.get(this.props.formSetting, 'description') || ''
373
611
  let cls = 'form-wrapper form'
374
612
  if (this.props.mode === 'attachment') cls += ' form-wrapper-attachment'
375
613
  return (
@@ -68,11 +68,11 @@ export default class FormPage extends Com {
68
68
  let descFont = sb.get(formSetting, 'text_style.font_family')
69
69
  let inputLabelFont = sb.get(formSetting, 'input_style.font_family')
70
70
  setting.form_background_color = formSetting.background_color
71
- setting.form_header_font_size = sb.get(formSetting, 'header_style.font_size') || '20'
71
+ setting.form_header_font_size = sb.get(formSetting, 'header_style.font_size') || '28'
72
72
  setting.form_header_font_family = headerFont
73
73
  setting.form_desc_font_size = sb.get(formSetting, 'text_style.font_size') || '16'
74
74
  setting.form_desc_font_family = descFont
75
- setting.form_input_label_font_size = sb.get(formSetting, 'input_style.font_size') || '14'
75
+ setting.form_input_label_font_size = sb.get(formSetting, 'input_style.font_size') || '16'
76
76
  setting.form_input_label_font_family = inputLabelFont
77
77
  let outcss = sb.replaceCssVariable(css, setting)
78
78
 
@@ -184,7 +184,12 @@ export default class FormPage extends Com {
184
184
  }
185
185
  if (this.state.loading) return
186
186
  this.setState({loading: true})
187
- let res = await this.props.store.checkFormToken(this.props.store.matchAccount().id, this.props.token)
187
+ let res
188
+ if (this.props.token) {
189
+ res = await this.props.store.checkFormToken(this.props.store.matchAccount().id, this.props.token)
190
+ } else {
191
+ res = await this.props.store.getPublicForm(this.props.formId)
192
+ }
188
193
  //await sb.sleep(5000)
189
194
  //res.error = 'true'
190
195
  if (res.error) {
@@ -212,11 +217,19 @@ export default class FormPage extends Com {
212
217
  }
213
218
  if (this.state.submiting) return
214
219
  this.setState({submiting: true})
215
- let res = await this.props.store.submitForm2(
216
- this.props.store.matchAccount().id,
217
- this.props.token,
218
- this.state.submission,
219
- )
220
+ let res
221
+ if (this.props.token) {
222
+ res = await this.props.store.submitForm2(
223
+ this.props.store.matchAccount().id,
224
+ this.props.token,
225
+ this.state.submission,
226
+ )
227
+ } else {
228
+ res = await this.props.store.submitPublicForm(this.props.formId, {
229
+ values: sb.get(this.state.submission, 'values') || [],
230
+ page_url: document.referrer || location.href,
231
+ })
232
+ }
220
233
  if (res.error) {
221
234
  this.setState({submiting: false, errorMessage: this.props.t('form_submission_error')})
222
235
  return