@configuratorware/configurator-frontendgui 1.57.13 → 1.57.14

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 (24) hide show
  1. package/App/Modules/Designer/Components/DesignerSelectionToolbox/DesignerSelectionToolbox.js +5 -1
  2. package/App/Modules/Designer/Containers/DesignerSelectionToolbox.js +10 -2
  3. package/App/Reducers/Configurator/Reducer.js +35 -14
  4. package/App/Reducers/DesignArea/Selectors.js +39 -7
  5. package/App/Reducers/Fonts/Selectors.js +35 -10
  6. package/App/Services/DesignerService.js +23 -5
  7. package/App/Shared/Components/AmountInput/index.js +65 -44
  8. package/App/Shared/Components/ProductVariant/index.js +1 -1
  9. package/package.json +4 -4
  10. package/src/App/Modules/Designer/Components/DesignerSelectionToolbox/DesignerSelectionToolbox.js +4 -0
  11. package/src/App/Modules/Designer/Containers/DesignerSelectionToolbox.js +5 -1
  12. package/src/App/Reducers/Configurator/Reducer.js +67 -17
  13. package/src/App/Reducers/Configurator/__tests__/Reducer.test.js +230 -30
  14. package/src/App/Reducers/DesignArea/Selectors.js +26 -0
  15. package/src/App/Reducers/DesignArea/__tests__/Selectors.test.js +19 -0
  16. package/src/App/Reducers/Fonts/Selectors.js +25 -5
  17. package/src/App/Reducers/Fonts/__tests__/Selectors.test.js +14 -1
  18. package/src/App/Services/DesignerService.js +28 -9
  19. package/src/App/Services/__tests__/DesignerService.test.js +111 -0
  20. package/src/App/Shared/Components/AmountInput/__tests__/AmountInput.blur.test.js +249 -0
  21. package/src/App/Shared/Components/AmountInput/__tests__/AmountInput.retyp.test.js +57 -0
  22. package/src/App/Shared/Components/AmountInput/__tests__/index.test.js +16 -0
  23. package/src/App/Shared/Components/AmountInput/index.js +42 -38
  24. package/src/App/Shared/Components/ProductVariant/index.js +1 -1
@@ -176,6 +176,117 @@ describe('Services/DesignerService', () => {
176
176
  );
177
177
  });
178
178
 
179
+ test('createColorizedTextContent uses styleColors when provided', () => {
180
+ const storeService = new (class StoreService {
181
+ static serviceName = 'StoreService';
182
+ state = {
183
+ fonts: [
184
+ {
185
+ name: 'Lobster',
186
+ family: 'Lobster',
187
+ isDefault: true,
188
+ },
189
+ ],
190
+ designArea: { selectedDesignArea: {}, filteredDesignAreas: [] },
191
+ configurator: { configuration: { designdata: {} } },
192
+ };
193
+ dispatch = jest.fn();
194
+ })();
195
+ ServiceLocator.provide(storeService);
196
+ ServiceLocator.provide(new DesignDataService());
197
+
198
+ const service = new DesignerService();
199
+ const colorData = {
200
+ identifier: '{"color":"blue","palette":"default"}',
201
+ value: '#0078b8',
202
+ };
203
+ const text = service.createColorizedTextContent('bulknames', 1, {
204
+ withPlaceholder: false,
205
+ styleColors: [colorData],
206
+ });
207
+ expect(text).toContain('color: #0078b8');
208
+ expect(text).toContain('data-color=\'{"color":"blue","palette":"default"}\'');
209
+ });
210
+
211
+ test('createColorizedTextContent uses toolbar font selection when provided', () => {
212
+ const storeService = new (class StoreService {
213
+ static serviceName = 'StoreService';
214
+ state = {
215
+ fonts: [
216
+ {
217
+ name: 'Lobster',
218
+ family: 'Lobster',
219
+ isDefault: true,
220
+ },
221
+ ],
222
+ designArea: { selectedDesignArea: {}, filteredDesignAreas: [] },
223
+ configurator: { configuration: { designdata: {} } },
224
+ };
225
+ dispatch = jest.fn();
226
+ })();
227
+ ServiceLocator.provide(storeService);
228
+ ServiceLocator.provide(new DesignDataService());
229
+
230
+ const service = new DesignerService();
231
+ const text = service.createColorizedTextContent('bulknames', 1, {
232
+ withPlaceholder: false,
233
+ fontItem: { family: 'Courier New' },
234
+ isBold: true,
235
+ isItalic: true,
236
+ });
237
+ expect(text).toContain('font-family: "Courier New";');
238
+ expect(text).toContain('font-weight: bold;');
239
+ expect(text).toContain('font-style: italic;');
240
+ });
241
+
242
+ test('bulk name toggle on and off keeps the same color and font styling', () => {
243
+ const storeService = new (class StoreService {
244
+ static serviceName = 'StoreService';
245
+ state = {
246
+ fonts: [
247
+ {
248
+ name: 'Lobster',
249
+ family: 'Lobster',
250
+ isDefault: true,
251
+ },
252
+ ],
253
+ designArea: { selectedDesignArea: {}, filteredDesignAreas: [] },
254
+ configurator: { configuration: { designdata: {} } },
255
+ };
256
+ dispatch = jest.fn();
257
+ })();
258
+ ServiceLocator.provide(storeService);
259
+ ServiceLocator.provide(new DesignDataService());
260
+
261
+ const service = new DesignerService();
262
+ const styleColors = [
263
+ {
264
+ identifier: '{"color":"blue","palette":"default"}',
265
+ value: '#0078b8',
266
+ },
267
+ ];
268
+ const fontOptions = {
269
+ styleColors,
270
+ fontItem: { family: 'Georgia' },
271
+ isBold: true,
272
+ isItalic: false,
273
+ };
274
+ const bulkOn = service.createColorizedTextContent('Name1,Name2', 1, {
275
+ withPlaceholder: false,
276
+ ...fontOptions,
277
+ });
278
+ const bulkOff = service.createColorizedTextContent('Replace me', 1, {
279
+ withPlaceholder: true,
280
+ ...fontOptions,
281
+ });
282
+
283
+ const sharedStyle = 'color: #0078b8; font-family: "Georgia"; font-weight: bold; font-style: normal;';
284
+ expect(bulkOn).toContain(sharedStyle);
285
+ expect(bulkOff).toContain(sharedStyle);
286
+ expect(bulkOff).toContain('data-placeholder="true"');
287
+ expect(bulkOn).not.toContain('data-placeholder="true"');
288
+ });
289
+
179
290
  describe('changeTextFontItem', () => {
180
291
  const createTextMock = (overrides = {}) => ({
181
292
  type: 'Text',
@@ -0,0 +1,249 @@
1
+ import React from 'react';
2
+ import { mount } from 'enzyme';
3
+ import AmountInput from 'App/Shared/Components/AmountInput';
4
+ import ProductVariant from 'App/Shared/Components/ProductVariant';
5
+
6
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
7
+
8
+ // records every value the input renders, to detect a visible revert ("flash")
9
+ const recordRenders = wrapper => {
10
+ const instance = wrapper
11
+ .find('AmountInput')
12
+ .first()
13
+ .instance();
14
+ const values = [];
15
+ const render = instance.render.bind(instance);
16
+ instance.render = () => {
17
+ values.push(instance.state.value);
18
+ return render();
19
+ };
20
+ return values;
21
+ };
22
+
23
+ // mimics Shared/Containers/ProductVariant: amounts live in the parent ("redux"),
24
+ // handleChange -> setSelectedAmount, handleMultipleAmountInputsChange -> setSelectedAmounts
25
+ class Harness extends React.Component {
26
+ state = { amounts: this.props.initialAmounts };
27
+ calls = [];
28
+ handleChange = (variant, size, value) => {
29
+ this.calls.push(['handleChange', (size || variant).identifier, value]);
30
+ this.setState(s => ({ amounts: { ...s.amounts, [(size || variant).identifier]: +value } }));
31
+ };
32
+ handleMultipleAmountInputsChange = (variant, sizes) => {
33
+ this.calls.push(['confirm', sizes]);
34
+ this.setState(s => ({ amounts: { ...s.amounts, ...sizes } }));
35
+ };
36
+ render() {
37
+ const { amounts } = this.state;
38
+ const amountData = this.props.variants.map(v => ({
39
+ ...v,
40
+ amount: amounts[v.identifier] || 0,
41
+ sizes: v.sizes.map(size => ({ ...size, amount: amounts[size.identifier] || 0 })),
42
+ }));
43
+ return (
44
+ <ProductVariant
45
+ amountData={amountData}
46
+ handleChange={this.handleChange}
47
+ handleMultipleAmountInputsChange={this.handleMultipleAmountInputsChange}
48
+ />
49
+ );
50
+ }
51
+ }
52
+
53
+ const singleVariant = [{ identifier: 'mug', title: 'mug', url: '', sizes: [] }];
54
+ const multiSizeVariant = [
55
+ {
56
+ identifier: 'shirt_red',
57
+ title: 'red',
58
+ url: '',
59
+ sizes: [
60
+ { identifier: 'shirt_red_s', size: 'Größe S', label: 'Größe S' },
61
+ { identifier: 'shirt_red_m', size: 'Größe M', label: 'Größe M' },
62
+ ],
63
+ },
64
+ ];
65
+
66
+ const typeAndBlur = async (input, typed) => {
67
+ input().simulate('focus');
68
+ if (typed !== undefined) {
69
+ input().simulate('change', { target: { value: typed } });
70
+ await sleep(20);
71
+ }
72
+ input().simulate('blur', { target: { value: typed } });
73
+ };
74
+
75
+ describe('ProductVariant, variant without sizes (amount input with value label)', () => {
76
+ const setup = () => {
77
+ const wrapper = mount(<Harness variants={singleVariant} initialAmounts={{ mug: 5 }} />);
78
+ const harness = wrapper.find(Harness).instance();
79
+ const input = () => wrapper.find('input').first();
80
+ return { wrapper, harness, input };
81
+ };
82
+
83
+ test('L1 focus + blur without editing keeps the label and sends no change', async () => {
84
+ const { wrapper, harness, input } = setup();
85
+ expect(input().prop('value')).toMatch(/^5 /);
86
+ await typeAndBlur(input);
87
+ wrapper.update();
88
+ expect(input().prop('value')).toMatch(/^5 /);
89
+ expect(harness.calls).toEqual([]);
90
+ });
91
+
92
+ test('L2 editing shows the new amount with label after blur', async () => {
93
+ const { wrapper, harness, input } = setup();
94
+ await typeAndBlur(input, '6');
95
+ wrapper.update();
96
+ expect(input().prop('value')).toMatch(/^6 /);
97
+ expect(harness.state.amounts).toEqual({ mug: 6 });
98
+ });
99
+
100
+ test('L3 editing sends the amount exactly once', async () => {
101
+ const { harness, input } = setup();
102
+ await typeAndBlur(input, '6');
103
+ expect(harness.calls).toEqual([['handleChange', 'mug', 6]]);
104
+ });
105
+ });
106
+
107
+ describe('ProductVariant, variant with several sizes (amounts confirmed via button)', () => {
108
+ const setup = () => {
109
+ const wrapper = mount(<Harness variants={multiSizeVariant} initialAmounts={{}} />);
110
+ wrapper
111
+ .find('button')
112
+ .first()
113
+ .simulate('click'); // "choose size"
114
+ const harness = wrapper.find(Harness).instance();
115
+ const input = () => wrapper.find('input').first();
116
+ return { wrapper, harness, input };
117
+ };
118
+
119
+ test('M1 typed size amount stays visible after blur', async () => {
120
+ const { wrapper, input } = setup();
121
+ const renders = recordRenders(wrapper);
122
+ await typeAndBlur(input, '3');
123
+ wrapper.update();
124
+ expect(input().prop('value')).toBe(3);
125
+ expect(renders.slice(renders.indexOf(3))).not.toContain(0); // no flash back to the old amount
126
+ });
127
+
128
+ test('M2 refocus + blur without editing keeps the typed amount', async () => {
129
+ const { wrapper, input } = setup();
130
+ await typeAndBlur(input, '3');
131
+ await typeAndBlur(input);
132
+ wrapper.update();
133
+ expect(input().prop('value')).toBe(3);
134
+ });
135
+
136
+ test('M3 confirm commits the typed amount', async () => {
137
+ const { wrapper, harness, input } = setup();
138
+ await typeAndBlur(input, '3');
139
+ wrapper
140
+ .find('button')
141
+ .last()
142
+ .simulate('click'); // "confirm selection"
143
+ expect(harness.state.amounts).toEqual({ shirt_red_s: 3 });
144
+ });
145
+ });
146
+
147
+ describe('AmountInput with debounce (CalculationWidget / AmountSelection)', () => {
148
+ class Controlled extends React.Component {
149
+ state = { value: 5 };
150
+ calls = [];
151
+ render() {
152
+ return (
153
+ <AmountInput
154
+ value={this.state.value}
155
+ onChangeDebounceWait={400}
156
+ onChange={value => {
157
+ this.calls.push(value);
158
+ this.setState({ value });
159
+ }}
160
+ />
161
+ );
162
+ }
163
+ }
164
+ const setup = () => {
165
+ const wrapper = mount(<Controlled />);
166
+ const parent = wrapper.find(Controlled).instance();
167
+ const input = () => wrapper.find('input');
168
+ return { wrapper, parent, input };
169
+ };
170
+
171
+ test('D1 quick blur commits typed value once, without flashing the old value', async () => {
172
+ const { wrapper, parent, input } = setup();
173
+ const renders = recordRenders(wrapper);
174
+ input().simulate('focus');
175
+ input().simulate('change', { target: { value: '50' } });
176
+ input().simulate('blur', { target: { value: '50' } });
177
+ await sleep(500);
178
+ wrapper.update();
179
+ expect(parent.calls).toEqual([50]);
180
+ expect(input().prop('value')).toBe(50);
181
+ expect(renders.slice(renders.indexOf(50))).not.toContain(5);
182
+ });
183
+
184
+ test('D2 blur after debounce fired does not send a duplicate', async () => {
185
+ const { parent, input } = setup();
186
+ input().simulate('focus');
187
+ input().simulate('change', { target: { value: '50' } });
188
+ await sleep(500);
189
+ input().simulate('blur', { target: { value: '50' } });
190
+ await sleep(500);
191
+ expect(parent.calls).toEqual([50]);
192
+ });
193
+
194
+ test('D3 focus + blur without editing sends nothing', async () => {
195
+ const { parent, input } = setup();
196
+ input().simulate('focus');
197
+ input().simulate('blur', { target: { value: '5' } });
198
+ await sleep(500);
199
+ expect(parent.calls).toEqual([]);
200
+ });
201
+
202
+ test('D4 emptied field falls back to min on blur', async () => {
203
+ const { wrapper, parent, input } = setup();
204
+ input().simulate('focus');
205
+ input().simulate('change', { target: { value: '' } });
206
+ input().simulate('blur', { target: { value: '' } });
207
+ await sleep(500);
208
+ wrapper.update();
209
+ expect(parent.calls[parent.calls.length - 1]).toBe(0);
210
+ expect(input().prop('value')).toBe(0);
211
+ });
212
+
213
+ test('D5 values above max are clamped', async () => {
214
+ const { wrapper, parent, input } = setup();
215
+ input().simulate('focus');
216
+ input().simulate('change', { target: { value: '99999999' } });
217
+ input().simulate('blur', { target: { value: '99999999' } });
218
+ await sleep(500);
219
+ wrapper.update();
220
+ expect(parent.calls).toEqual([999999]);
221
+ expect(input().prop('value')).toBe(999999);
222
+ });
223
+
224
+ test('D6 readOnly input picks up prop changes while focused', () => {
225
+ const wrapper = mount(<AmountInput value={5} readOnly onChange={jest.fn()} />);
226
+ wrapper.find('input').simulate('focus');
227
+ wrapper.setProps({ value: 8 });
228
+ wrapper.update();
229
+ expect(wrapper.find('input').prop('value')).toBe(8);
230
+ });
231
+
232
+ describe('ProductVariant, clearing an amount to retype it', () => {
233
+ it('never sends 0 for a variant without sizes', async () => {
234
+ const wrapper = mount(<Harness variants={singleVariant} initialAmounts={{ mug: 5 }} />);
235
+ const harness = wrapper.find(Harness).instance();
236
+ const input = () => wrapper.find('input').first();
237
+
238
+ input().simulate('focus');
239
+ input().simulate('change', { target: { value: '' } });
240
+ await sleep(20);
241
+ input().simulate('change', { target: { value: '6' } });
242
+ await sleep(20);
243
+ input().simulate('blur');
244
+
245
+ expect(harness.calls).not.toContainEqual(['handleChange', 'mug', 0]);
246
+ expect(harness.calls).toContainEqual(['handleChange', 'mug', 6]);
247
+ });
248
+ });
249
+ });
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { mount } from 'enzyme';
3
+ import AmountInput from 'App/Shared/Components/AmountInput';
4
+
5
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
6
+
7
+ describe('AmountInput, clearing the field to retype', () => {
8
+ const setup = () => {
9
+ const onChange = jest.fn();
10
+ const wrapper = mount(<AmountInput value={2} onChange={onChange} onChangeDebounceWait={10} />);
11
+ const input = () => wrapper.find('input').first();
12
+ input().simulate('focus');
13
+ return { wrapper, onChange, input };
14
+ };
15
+
16
+ it('does not send 0 while the field is empty and the user retypes', async () => {
17
+ const { onChange, input } = setup();
18
+
19
+ input().simulate('change', { target: { value: '' } });
20
+ await sleep(30);
21
+ input().simulate('change', { target: { value: '3' } });
22
+ await sleep(30);
23
+ input().simulate('blur');
24
+
25
+ expect(onChange).not.toHaveBeenCalledWith(0);
26
+ expect(onChange).toHaveBeenCalledWith(3);
27
+ });
28
+
29
+ it('commits 0 on blur when the field stays empty', async () => {
30
+ const { onChange, input } = setup();
31
+
32
+ input().simulate('change', { target: { value: '' } });
33
+ await sleep(30);
34
+ input().simulate('blur');
35
+
36
+ expect(onChange.mock.calls).toEqual([[0]]);
37
+ });
38
+
39
+ it('commits 0 on unmount when the field is left empty', async () => {
40
+ const { wrapper, onChange, input } = setup();
41
+
42
+ input().simulate('change', { target: { value: '' } });
43
+ await sleep(30);
44
+ wrapper.unmount();
45
+
46
+ expect(onChange.mock.calls).toEqual([[0]]);
47
+ });
48
+
49
+ it('still sends a typed 0 right away', async () => {
50
+ const { onChange, input } = setup();
51
+
52
+ input().simulate('change', { target: { value: '0' } });
53
+ await sleep(30);
54
+
55
+ expect(onChange).toHaveBeenCalledWith(0);
56
+ });
57
+ });
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import { mount } from 'enzyme';
3
+ import AmountInput from '../index';
4
+
5
+ describe('Shared/Components/AmountInput', () => {
6
+ test('typed value survives blur before debounce', async () => {
7
+ const onChange = jest.fn();
8
+ const wrapper = mount(<AmountInput value={5} onChange={onChange} onChangeDebounceWait={400} />);
9
+ const input = wrapper.find('input');
10
+ input.simulate('focus');
11
+ input.simulate('change', { target: { value: '50' } });
12
+ input.simulate('blur', { target: { value: '50' } });
13
+ await new Promise(r => setTimeout(r, 500));
14
+ expect(onChange).toHaveBeenCalledWith(50);
15
+ });
16
+ });
@@ -58,7 +58,7 @@ const styles = theme => ({
58
58
 
59
59
  class AmountInput extends React.Component {
60
60
  static propTypes = {
61
- value: PropTypes.string,
61
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
62
62
  classes: PropTypes.object,
63
63
  inputProps: PropTypes.object,
64
64
  rootProps: PropTypes.string,
@@ -91,29 +91,22 @@ class AmountInput extends React.Component {
91
91
  };
92
92
 
93
93
  componentDidMount() {
94
- const {
95
- props: { value },
96
- } = this;
97
- this.setState({ value });
94
+ this.setState({ value: this.props.value });
98
95
  }
99
96
 
100
97
  componentDidUpdate(prevProps) {
101
- const {
102
- props: { value },
103
- } = this;
104
- if (prevProps.value !== value && !this.state.changeInprogress) {
105
- this.updateDebounced();
98
+ const { value, readOnly } = this.props;
99
+ if (prevProps.value !== value && (readOnly || !this.state.changeInprogress)) {
100
+ this.setState({ value });
106
101
  }
107
102
  }
108
103
 
109
- updateDebounced = debounce(() => {
110
- const {
111
- props: { value },
112
- } = this;
113
- if (value !== this.state.value) {
114
- this.setState({ value });
104
+ componentWillUnmount() {
105
+ this.onChangeDebounced.cancel();
106
+ if (this.hasPendingChange) {
107
+ this.props.onChange(this.state.value === '' ? this.props.min : this.state.value);
115
108
  }
116
- }, 500);
109
+ }
117
110
 
118
111
  validateKey = event => {
119
112
  const regEx = /^[0-9\b]+$/;
@@ -124,41 +117,52 @@ class AmountInput extends React.Component {
124
117
  }
125
118
  };
126
119
 
120
+ hasPendingChange = false;
121
+
122
+ // An empty field means the user is retyping: committing 0 here would drop data tied to the amount
123
+ // (e.g. bulk names). It is committed on blur or unmount instead.
127
124
  onChangeDebounced = debounce(() => {
128
- this.props.onChange(this.state.value === '' ? 0 : this.state.value);
125
+ if (this.state.value === '') {
126
+ return;
127
+ }
128
+ this.hasPendingChange = false;
129
+ this.props.onChange(this.state.value);
129
130
  }, this.props.onChangeDebounceWait);
130
131
 
131
132
  onChange = ({ target: { value: inputValue } }) => {
132
133
  const { min, max } = this.props;
133
- const value = !inputValue ? inputValue : Math.min(max, Math.max(min, parseInt(inputValue)));
134
- this.setState(
135
- {
136
- value: isNaN(value) ? '' : value,
137
- },
138
- this.onChangeDebounced
139
- );
134
+ const value = !inputValue ? inputValue : Math.min(max, Math.max(min, parseInt(inputValue, 10)));
135
+ this.hasPendingChange = true;
136
+ this.setState({ value: Number.isNaN(value) ? '' : value }, this.onChangeDebounced);
140
137
  };
141
138
 
142
139
  onBlur = event => {
143
- const {
144
- target: { value },
145
- } = event;
146
- const { onChange, onBlur, min, hasValueLabel } = this.props;
147
- this.setState({ changeInprogress: false });
148
- if (value === '') {
149
- const initialValue = min;
150
- this.setState({ value: initialValue });
151
- onChange(initialValue);
140
+ const { onChange, onBlur, min, readOnly, hasValueLabel } = this.props;
141
+
142
+ if (readOnly) {
143
+ this.setState({ changeInprogress: false, value: this.props.value });
144
+ onBlur && onBlur(event);
145
+ return;
152
146
  }
153
- if (hasValueLabel) {
154
- this.updateDebounced();
147
+
148
+ this.onChangeDebounced.cancel();
149
+ const isCleared = this.state.value === '';
150
+ const shouldCommit = this.hasPendingChange || isCleared;
151
+ const committedValue = isCleared ? min : this.state.value;
152
+ this.hasPendingChange = false;
153
+
154
+ this.setState({ changeInprogress: false, value: hasValueLabel ? this.props.value : committedValue });
155
+ if (shouldCommit) {
156
+ onChange(committedValue);
155
157
  }
156
158
  onBlur && onBlur(event);
157
159
  };
158
160
 
159
161
  onFocus = event => {
160
- const { onFocus } = this.props;
161
- this.setState({ changeInprogress: true });
162
+ const { onFocus, readOnly } = this.props;
163
+ if (!readOnly) {
164
+ this.setState({ changeInprogress: true });
165
+ }
162
166
  onFocus && onFocus(event);
163
167
  };
164
168
 
@@ -374,8 +374,8 @@ class ProductVariant extends React.Component {
374
374
  {get(variant, 'sizes.length', 0) === 0 && (
375
375
  <AmountInput
376
376
  value={variant.amount + ' ' + t('amountSelection.amount')}
377
- hasValueLabel={true}
378
377
  amountLabel={t('productVariant.amountLabel')}
378
+ hasValueLabel={true}
379
379
  onChange={this.handleChange(variant)}
380
380
  onClick={this.handleBulkNameForm(variant)}
381
381
  error={hasError(variant.identifier, error)}