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