I’ve been meaning to write this for nearly a year, but I held off hoping things would change with the next release. They didn’t, so I’m writing this: the Clipboard plugin for the Ext.grid.Panel class – which provides cut-and-paste support for the enhanced table widget – is borked by design. It does stupid things, and Sencha says it should do the stupid things. In this post I share what these things are, and how I’ve overriden the default behaviour to do something hopefully less stupid. Warning: this is a rant.
Updated for ExtJS 7
Problems 1 and 4 are now fixed (as of ExtJS 6.5). Problems 2 and 3 are still there, and they are also a problem in the Modern toolkit, as shown in this fiddle.
What’s stupid about it?
For reference, you can see the behaviour I’m going to describe by looking at this example fiddle.
I’ve got a simple task that I want to be able to achieve. I want to be able to select part or all of a table, copy that table to the clipboard, paste it into something like Excel, tweak the values there, then paste back. And I can’t do that.
Problem 1: Copy and immediately paste doesn’t work.
If you select, say, two cells, and copy them, then immediately paste them, the “paste” starts at the focused cell. That’s great if the focused cell is at the top, but if it’s at the bottom – say, if you selected a cell, then hit Shift-DownArrow to select the cell underneath it, that’s doesn’t work.
If I’ve got a selection, then the paste should go into that selection. And most definitely, if I copy then paste back immediately, it shouldn’t change anything.
This is stupid behaviour – but, to Sencha’s credit, they’ve acknowledged this as a bug (EXTJS-19866, for the record). I mean, they haven’t fixed it in almost a year, despite being given a fix, and they haven’t listed it as a known issue in the release notes for subsequent versions – but at least they’ve got a bug that’s open.
Problem 2: You can paste into read-only fields.
In the example link above, there is only one column that you can edit by typing – the ‘Age’ column. All the other columns are not editable. But you can select a cell or two – say, copying ‘Bart’ from the Name column – and then paste it into a non-editable cell, such as the email.
This is borked-by-design. Columns without editors are not editable – you shouldn’t be able to bypass that by using the clipboard. Bear in mind that the only way to copy data out is to enable the clipboard plugin, which immediately exposes this loophole. This is seriously stupid behaviour.
Sencha closed the ticket (EXTJS-19867) as “Not a Bug”. Their response to me was:
The first one [EXTJS-19867] was deemed not a bug (but perhaps a feature request) as it would be cumbersome for other users to have to create an editor in every column just to allow pasting of values. As stated by engineering “This is a paste – not an edit”.
In what world is pasting not editing? I mean, seriously. Or, to put it another way, why on earth would you want to be able to paste values into something without also being able to just type the values in via an edit field? The two go together. Remember: if you want to copy values at all, you need to put this plugin in, and it doesn’t come in a read-only mode.
At the very least, Sencha could provide an option to disable pasting into columns without editors. But no – the desired behaviour is to be able to paste into read-only fields. Completely and utterly borked.
Problem 3: Pasting doesn’t go via the editor
Okay – let’s say you think you should be able to paste into fields without editors. I think that’s stupid, but maybe you’ve got a different opinion. But surely, if an editor is provided, the paste operation should pay attention to it?
In the example link above, I show a table where there are two age-related columns. One – the age in months – shows the “native storage format” for the data; I’m storing the age of the Simpsons family in months. This column is exposed to demonstrate the problem. The other column, ‘Age’, uses a renderer
to convert from months to years, and also has an editor that converts from the ‘raw’ value in months to the ‘value’ displayed in years and vice-versa. Thus, if you type an age in – say, changing Bart for 10 years old from 10 to, say, 37 –, the Age In Months value will jump to 444.
If you copy an age value, however, – say, Lisa’s age (8 years old) – and then paste the this value back in, it sets the raw value. So it’s copied out the rendered value (‘8.00’ – you can check that by pasting into another application) correctly, but isn’t allowing you to paste in via the renderer.
As it turns out, Sencha has multiple modes for the clipboard plugin. One of the modes – ‘cell’ – is only useful for cut-and-paste in the same app, so that’s not much good. A second – ‘html’ – copies out the rendered HTML in the cell. Potentially useful for copying (e.g. it would preserve formatting and links), but not good at all for pasting back in. The last two – ‘text’ vs ‘raw’ – are interesting. With the first (the default), you copy what you see on screen, but with the second, you copy the native value. In both cases, though, you paste back to the native value.
All of that is important because it means Sencha could add an extra mode – one where you copy the text, and then paste back via the editor. Call it the “do-what-you-see-on-screen” approach, where I can copy out the value ‘8.00’, paste the ‘8.00’ back, and have the same nett effect as if I had used the editor to type ‘8.00’ instead.
Using the editor also would allow individual cells in a mostly editable column to be read-only, as the editor can veto edits.
Sencha at least acknowledged this one (ticket EXTJS-16868) as a bug, but marked it as “Won’t Fix”. In their own words:
As for the copy/paste issue of transforming the pasted value using one of the editor routines – it would be impossible for the framework to assume that this is to always be the desired behaviour, and presumably not everyone would wish this to be a desired behaviour. Any required transformation would have to be specifically custom made by yourself.
Well, yes – any “required transformation” would need to be custom made. But Sencha’s provided the hooks – the “rawToValue” and “valueToRaw” functions on the editor. I’m just saying maybe they should have an option to use it. Nor would it be necessary for the system to determine that is desired – it can be another option, sitting alongside ‘text’ and ‘raw’, perhaps, or a separate option for the paste behaviour, with a default of ‘raw’ (the current behaviour). I would then bitch about how the default is stupid, but at least the correct behaviour would be available.
Providing the ability to have editors on cell columns, and then ignoring them when pasting values in – that’s borked-by-design.
Problem 4 – pasting while editing
Sometimes you want to be able to edit a field and paste into the field while editing. This was broken in ExtJS 6.0.0. However, this at least was acknowledged as a bug (EXTJS-16869) and fixed in 6.0.2. I’ll give them a pass on that one – bugs happen, and they fixed it.
The fix
When I reported these problems to Sencha, I also provided code that implemented what I see as the correct behaviour. This could have been used as is, or adapted to provide additional configuration options.
This fix can be seen in this example fiddle, with the code below.
Ext.define('Twasink.override.Clipboard', { | |
override: 'Ext.grid.plugin.Clipboard', | |
// The default putCellData doesn't pay attention to the selection, or the editor. This is a fix | |
putCellData: function(data, format) { | |
var view = this.getCmp().getView(); | |
// OVERRIDE Lines 141 to 157 in the ExtJS 6.2.0 source. This fixes a bug where the paste event starts where the | |
// navigation position is, which may well be at the bottom of the selection. | |
var destination = this.determineDestination(view); | |
var destinationStartColumn = destination.colIdx; | |
// Decode the values into an m x n array (m rows, n columns) | |
var values = Ext.util.TSV.decode(data); | |
var recCount = values.length; | |
var colCount = recCount ? values[0].length : 0; | |
var maxRowIdx = view.dataSource.getCount() - 1; | |
var maxColIdx = view.getVisibleColumnManager().getColumns().length - 1; | |
for (var sourceRowIdx = 0; sourceRowIdx < recCount; sourceRowIdx++) { | |
var row = values[sourceRowIdx]; | |
var dataObject = {} | |
// Collect new values in dataObject | |
for (var sourceColIdx = 0; sourceColIdx < colCount; sourceColIdx++) { | |
// OVERRIDE lines 162 through to 181 of the ExtJS 6.2.0 version. This fixes bugs about not respecting the editor | |
// when pasting values back in. | |
this.transferValue(destination, dataObject, format, row, sourceColIdx); | |
// If we are at the end of the destination row, break the column loop. | |
if (destination.colIdx === maxColIdx) { | |
break; | |
} | |
destination.setColumn(destination.colIdx + 1); | |
} | |
// Update the record in one go. | |
destination.record.set(dataObject); | |
// If we are at the end of the destination store, break the row loop. | |
if (destination.rowIdx === maxRowIdx) { | |
break; | |
} | |
// Jump to next row in destination | |
destination.setPosition(destination.rowIdx + 1, destinationStartColumn); | |
} | |
}, | |
privates: { | |
determineDestination: function(view) { | |
var selectionModel = this.getCmp().getSelectionModel(); | |
var selection = selectionModel.getSelected(); | |
var destination; | |
if (selection) { | |
destination = new Ext.grid.CellContext(view).setPosition(selection.getFirstRowIndex(), selection.getFirstColumnIndex()); | |
} else { | |
var navModel = view.getNavigationModel(); | |
var currentPosition = navModel.getPosition(); | |
if (position) { | |
// Create a new Context based upon the outermost View. | |
// NavigationModel works on local views. TODO: remove this step when NavModel is fixed to use outermost view in locked grid. | |
// At that point, we can use navModel.getPosition() | |
destination = new Ext.grid.CellContext(view).setPosition(position.record, position.column); | |
} else { | |
destination = new Ext.grid.CellContext(view).setPosition(0, 0); | |
} | |
} | |
return destination; | |
}, | |
transferValue: function(destination, dataObject, format, row, sourceColIdx) { | |
if (format == 'html') { return; } | |
var column = destination.column; | |
var editor = column.editor || column.getEditor(); | |
if (!editor) { return; } // Thou shalt not edit a column that is not editable | |
var dataIndex = column.dataIndex; | |
if (!dataIndex) { return; } // Thou shalt not edit a column that is not mapped. | |
if (this.editIsVetoed(destination)) { return; } | |
var value = row[sourceColIdx]; | |
if (editor.rawToValue) { // If there is a convertor, apply it | |
value = editor.rawToValue(value); | |
} | |
dataObject[dataIndex] = value ; | |
}, | |
editIsVetoed: function(destination) { | |
var cmp = this.getCmp(); | |
var editorPlugin = Ext.Array.findBy(cmp.getPlugins(), function(plugin) { return plugin instanceof Ext.grid.plugin.Editing; }); | |
if (!editorPlugin) { return true; } // can't edit without an editor, right? | |
var context = editorPlugin.getEditingContext(destination.record, destination.column) | |
editorPlugin.fireEvent("beforeedit", editorPlugin, context); | |
return context.cancel; | |
} | |
} | |
}, function() { | |
if (Ext.getVersion('core').getShortVersion() !== '620981') { | |
console.warn("EXTJS Version has been updated from 6.2.0.981. This bug _might_ have been fixed in 6.2.1. Check please!") | |
} | |
}) |
Great article! Unbelivable why Sencha is not fixing the misbehaviour of the clipboard plugin.
I have a love/hate relationship with ExtJS and I can certainly understand your frustration. I’ve had similar experiences where I have seriously pondered why I even use it and if I should move to something else. Some of their decisions to do things a certain way have seemed unintuitive and backward. But their general framework still really resonates with me and you can do some really cool things with it.
Much the same. I do know why I use it, though – its collection of UI widgets is second to none.