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