]> git.proxmox.com Git - proxmox-widget-toolkit.git/blame - form/ComboGrid.js
fix #2421: ComboGrid: correctly validate multiSelect variant
[proxmox-widget-toolkit.git] / form / ComboGrid.js
CommitLineData
066babdc
DM
1/*
2 * ComboGrid component: a ComboBox where the dropdown menu (the
3 * "Picker") is a Grid with Rows and Columns expects a listConfig
4 * object with a columns property roughly based on the GridPicker from
5 * https://www.sencha.com/forum/showthread.php?299909
6 *
7*/
8
9Ext.define('Proxmox.form.ComboGrid', {
10 extend: 'Ext.form.field.ComboBox',
11 alias: ['widget.proxmoxComboGrid'],
12
13 // this value is used as default value after load()
14 preferredValue: undefined,
15
16 // hack: allow to select empty value
17 // seems extjs does not allow that when 'editable == false'
18 onKeyUp: function(e, t) {
19 var me = this;
20 var key = e.getKey();
21
22 if (!me.editable && me.allowBlank && !me.multiSelect &&
23 (key == e.BACKSPACE || key == e.DELETE)) {
24 me.setValue('');
25 }
26
27 me.callParent(arguments);
28 },
29
227159ec
TL
30 config: {
31 skipEmptyText: false,
1ccb53ec 32 notFoundIsValid: false,
227159ec
TL
33 deleteEmpty: false,
34 },
35
066babdc
DM
36 // needed to trigger onKeyUp etc.
37 enableKeyEvents: true,
38
3ab80554
DC
39 editable: false,
40
94953ba8
DC
41 triggers: {
42 clear: {
43 cls: 'pmx-clear-trigger',
44 weight: -1,
45 hidden: true,
46 handler: function() {
47 var me = this;
48 me.setValue('');
49 }
50 }
51 },
52
53 setValue: function(value) {
54 var me = this;
55 me.triggers.clear.setVisible(!!value && me.allowBlank);
56 return me.callParent([value]);
57 },
58
066babdc
DM
59 // override ExtJS method
60 // if the field has multiSelect enabled, the store is not loaded, and
61 // the displayfield == valuefield, it saves the rawvalue as an array
62 // but the getRawValue method is only defined in the textfield class
63 // (which has not to deal with arrays) an returns the string in the
64 // field (not an array)
65 //
66 // so if we have multiselect enabled, return the rawValue (which
67 // should be an array) and else we do callParent so
68 // it should not impact any other use of the class
69 getRawValue: function() {
70 var me = this;
71 if (me.multiSelect) {
72 return me.rawValue;
73 } else {
74 return me.callParent();
75 }
76 },
77
227159ec
TL
78 getSubmitData: function() {
79 var me = this;
80
81 let data = null;
82 if (!me.disabled && me.submitValue) {
83 let val = me.getSubmitValue();
84 if (val !== null) {
85 data = {};
86 data[me.getName()] = val;
87 } else if (me.getDeleteEmpty()) {
88 data = {};
89 data['delete'] = me.getName();
90 }
91 }
92 return data;
93 },
94
95 getSubmitValue: function() {
96 var me = this;
97
98 var value = me.callParent();
99 if (value !== '') {
100 return value;
101 }
102
103 return me.getSkipEmptyText() ? null: value;
104 },
105
a8131b5b
TL
106 setAllowBlank: function(allowBlank) {
107 this.allowBlank = allowBlank;
108 this.validate();
109 },
110
066babdc
DM
111// override ExtJS protected method
112 onBindStore: function(store, initial) {
113 var me = this,
114 picker = me.picker,
115 extraKeySpec,
116 valueCollectionConfig;
117
118 // We're being bound, not unbound...
119 if (store) {
120 // If store was created from a 2 dimensional array with generated field names 'field1' and 'field2'
121 if (store.autoCreated) {
122 me.queryMode = 'local';
123 me.valueField = me.displayField = 'field1';
124 if (!store.expanded) {
125 me.displayField = 'field2';
126 }
127
128 // displayTpl config will need regenerating with the autogenerated displayField name 'field1'
129 me.setDisplayTpl(null);
130 }
131 if (!Ext.isDefined(me.valueField)) {
132 me.valueField = me.displayField;
133 }
134
135 // Add a byValue index to the store so that we can efficiently look up records by the value field
136 // when setValue passes string value(s).
137 // The two indices (Ext.util.CollectionKeys) are configured unique: false, so that if duplicate keys
138 // are found, they are all returned by the get call.
139 // This is so that findByText and findByValue are able to return the *FIRST* matching value. By default,
140 // if unique is true, CollectionKey keeps the *last* matching value.
141 extraKeySpec = {
142 byValue: {
143 rootProperty: 'data',
144 unique: false
145 }
146 };
147 extraKeySpec.byValue.property = me.valueField;
148 store.setExtraKeys(extraKeySpec);
149
150 if (me.displayField === me.valueField) {
151 store.byText = store.byValue;
152 } else {
153 extraKeySpec.byText = {
154 rootProperty: 'data',
155 unique: false
156 };
157 extraKeySpec.byText.property = me.displayField;
158 store.setExtraKeys(extraKeySpec);
159 }
160
161 // We hold a collection of the values which have been selected, keyed by this field's valueField.
162 // This collection also functions as the selected items collection for the BoundList's selection model
163 valueCollectionConfig = {
164 rootProperty: 'data',
165 extraKeys: {
166 byInternalId: {
167 property: 'internalId'
168 },
169 byValue: {
170 property: me.valueField,
171 rootProperty: 'data'
172 }
173 },
174 // Whenever this collection is changed by anyone, whether by this field adding to it,
175 // or the BoundList operating, we must refresh our value.
176 listeners: {
177 beginupdate: me.onValueCollectionBeginUpdate,
178 endupdate: me.onValueCollectionEndUpdate,
179 scope: me
180 }
181 };
182
183 // This becomes our collection of selected records for the Field.
184 me.valueCollection = new Ext.util.Collection(valueCollectionConfig);
185
186 // We use the selected Collection as our value collection and the basis
187 // for rendering the tag list.
188
189 //proxmox override: since the picker is represented by a grid panel,
190 // we changed here the selection to RowModel
191 me.pickerSelectionModel = new Ext.selection.RowModel({
192 mode: me.multiSelect ? 'SIMPLE' : 'SINGLE',
193 // There are situations when a row is selected on mousedown but then the mouse is dragged to another row
194 // and released. In these situations, the event target for the click event won't be the row where the mouse
195 // was released but the boundview. The view will then determine that it should fire a container click, and
196 // the DataViewModel will then deselect all prior selections. Setting `deselectOnContainerClick` here will
197 // prevent the model from deselecting.
198 deselectOnContainerClick: false,
199 enableInitialSelection: false,
200 pruneRemoved: false,
201 selected: me.valueCollection,
202 store: store,
203 listeners: {
204 scope: me,
205 lastselectedchanged: me.updateBindSelection
206 }
207 });
208
209 if (!initial) {
210 me.resetToDefault();
211 }
212
213 if (picker) {
214 picker.setSelectionModel(me.pickerSelectionModel);
215 if (picker.getStore() !== store) {
216 picker.bindStore(store);
217 }
218 }
219 }
220 },
221
222 // copied from ComboBox
223 createPicker: function() {
224 var me = this;
225 var picker;
226
227 var pickerCfg = Ext.apply({
228 // proxmox overrides: display a grid for selection
229 xtype: 'gridpanel',
230 id: me.pickerId,
231 pickerField: me,
232 floating: true,
233 hidden: true,
234 store: me.store,
235 displayField: me.displayField,
236 preserveScrollOnRefresh: true,
237 pageSize: me.pageSize,
238 tpl: me.tpl,
239 selModel: me.pickerSelectionModel,
240 focusOnToFront: false
241 }, me.listConfig, me.defaultListConfig);
242
243 picker = me.picker || Ext.widget(pickerCfg);
244
245 if (picker.getStore() !== me.store) {
246 picker.bindStore(me.store);
247 }
248
249 if (me.pageSize) {
250 picker.pagingToolbar.on('beforechange', me.onPageChange, me);
251 }
252
253 // proxmox overrides: pass missing method in gridPanel to its view
254 picker.refresh = function() {
255 picker.getSelectionModel().select(me.valueCollection.getRange());
256 picker.getView().refresh();
257 };
258 picker.getNodeByRecord = function() {
259 picker.getView().getNodeByRecord(arguments);
260 };
261
262 // We limit the height of the picker to fit in the space above
263 // or below this field unless the picker has its own ideas about that.
264 if (!picker.initialConfig.maxHeight) {
265 picker.on({
266 beforeshow: me.onBeforePickerShow,
267 scope: me
268 });
269 }
270 picker.getSelectionModel().on({
271 beforeselect: me.onBeforeSelect,
272 beforedeselect: me.onBeforeDeselect,
273 focuschange: me.onFocusChange,
274 selectionChange: function (sm, selectedRecords) {
275 var me = this;
276 if (selectedRecords.length) {
277 me.setValue(selectedRecords);
278 me.fireEvent('select', me, selectedRecords);
279 }
280 },
281 scope: me
282 });
283
284 // hack for extjs6
285 // when the clicked item is the same as the previously selected,
286 // it does not select the item
287 // instead we hide the picker
288 if (!me.multiSelect) {
289 picker.on('itemclick', function (sm,record) {
290 if (picker.getSelection()[0] === record) {
291 picker.hide();
292 }
293 });
294 }
295
296 // when our store is not yet loaded, we increase
297 // the height of the gridpanel, so that we can see
298 // the loading mask
299 //
300 // we save the minheight to reset it after the load
301 picker.on('show', function() {
302 if (me.enableLoadMask) {
303 me.savedMinHeight = picker.getMinHeight();
304 picker.setMinHeight(100);
305 }
306 });
307
308 picker.getNavigationModel().navigateOnSpace = false;
309
310 return picker;
311 },
312
35eec238
TM
313 clearLocalFilter: function() {
314 var me = this,
315 filter = me.queryFilter;
316
317 if (filter) {
318 me.queryFilter = null;
319 me.changingFilters = true;
320 me.store.removeFilter(filter, true);
321 me.changingFilters = false;
322 }
323 },
324
1ccb53ec
TL
325 isValueInStore: function(value) {
326 var me = this;
327 var store = me.store;
328 var found = false;
329
330 if (!store) {
331 return found;
332 }
333
35eec238
TM
334 // Make sure the current filter is removed before checking the store
335 // to prevent false negative results when iterating over a filtered store.
336 // All store.find*() method's operate on the filtered store.
337 if (me.queryFilter && me.queryMode === 'local' && me.clearFilterOnBlur) {
338 me.clearLocalFilter();
339 }
340
1ccb53ec
TL
341 if (Ext.isArray(value)) {
342 Ext.Array.each(value, function(v) {
343 if (store.findRecord(me.valueField, v)) {
344 found = true;
345 return false; // break
346 }
347 });
348 } else {
349 found = !!store.findRecord(me.valueField, value);
350 }
351
352 return found;
353 },
354
355 validator: function (value) {
356 var me = this;
357
358 if (!value) {
359 return true; // handled later by allowEmpty in the getErrors call chain
360 }
361
c59a0a3e
TL
362 // we normally get here the displayField as value, but if a valueField
363 // is configured we need to get the "actual" value, to ensure it is in
364 // the store. Below check is copied from ExtJS 6.0.2 ComboBox source
013cbd64
DC
365 //
366 // we also have to get the 'real' value if the we have a mulitSelect
367 // Field but got a non array value
368 if ((me.valueField && me.valueField !== me.displayField) ||
369 (me.multiSelect && !Ext.isArray(value))) {
c59a0a3e
TL
370 value = me.getValue();
371 }
372
1ccb53ec
TL
373 if (!(me.notFoundIsValid || me.isValueInStore(value))) {
374 return gettext('Invalid Value');
375 }
376
377 return true;
378 },
379
066babdc
DM
380 initComponent: function() {
381 var me = this;
382
066babdc
DM
383 Ext.apply(me, {
384 queryMode: 'local',
385 matchFieldWidth: false
386 });
387
388 Ext.applyIf(me, { value: ''}); // hack: avoid ExtJS validate() bug
389
390 Ext.applyIf(me.listConfig, { width: 400 });
391
392 me.callParent();
393
394 // Create the picker at an early stage, so it is available to store the previous selection
395 if (!me.picker) {
396 me.createPicker();
397 }
398
39d99149
DC
399 if (me.editable) {
400 // The trigger.picker causes first a focus event on the field then
401 // toggles the selection picker. Thus skip expanding in this case,
402 // else our focus listner expands and the picker.trigger then
403 // collapses it directly afterwards.
404 Ext.override(me.triggers.picker, {
405 onMouseDown : function (e) {
406 // copied "should we focus" check from Ext.form.trigger.Trigger
407 if (e.pointerType !== 'touch' && !this.field.owns(Ext.Element.getActiveElement())) {
408 me.skip_expand_on_focus = true;
409 }
410 this.callParent(arguments);
411 }
412 });
413
414 me.on("focus", function(me) {
415 if (!me.isExpanded && !me.skip_expand_on_focus) {
416 me.expand();
417 }
418 me.skip_expand_on_focus = false;
419 });
420 }
421
066babdc
DM
422 me.mon(me.store, 'beforeload', function() {
423 if (!me.isDisabled()) {
424 me.enableLoadMask = true;
425 }
426 });
427
428 // hack: autoSelect does not work
429 me.mon(me.store, 'load', function(store, r, success, o) {
430 if (success) {
431 me.clearInvalid();
432
433 if (me.enableLoadMask) {
434 delete me.enableLoadMask;
435
436 // if the picker exists,
437 // we reset its minheight to the saved var/0
438 // we have to update the layout, otherwise the height
439 // gets not recalculated
440 if (me.picker) {
441 me.picker.setMinHeight(me.savedMinHeight || 0);
442 delete me.savedMinHeight;
443 me.picker.updateLayout();
444 }
445 }
446
447 var def = me.getValue() || me.preferredValue;
448 if (def) {
449 me.setValue(def, true); // sync with grid
450 }
451 var found = false;
452 if (def) {
1ccb53ec 453 found = me.isValueInStore(def);
066babdc
DM
454 }
455
456 if (!found) {
457 var rec = me.store.first();
458 if (me.autoSelect && rec && rec.data) {
459 def = rec.data[me.valueField];
460 me.setValue(def, true);
461 } else {
f59a7b23 462 me.setValue(def);
1ccb53ec
TL
463 if (!me.notFoundIsValid) {
464 me.markInvalid(gettext('Invalid Value'));
465 }
066babdc
DM
466 }
467 }
468 }
469 });
470 }
471});