diff --git a/changelog.md b/changelog.md index 3b3bad3df..2cdda8f1f 100644 --- a/changelog.md +++ b/changelog.md @@ -23,3 +23,4 @@ * Use Django REST framework status code constants instead of integers. * Added long text field. * Fixed not refreshing token bug and improved authentication a little bit. +* Introduced copy, paste and delete functionality of selected fields. diff --git a/web-frontend/modules/database/components/view/grid/GridViewField.vue b/web-frontend/modules/database/components/view/grid/GridViewField.vue index 5db34855d..2b6c91148 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewField.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewField.vue @@ -13,6 +13,7 @@ <script> import { isElement } from '@baserow/modules/core/utils/dom' +import { copyToClipboard } from '@baserow/modules/database/utils/clipboard' export default { name: 'GridViewField', @@ -105,37 +106,75 @@ export default { } document.body.addEventListener('click', this.$el.clickOutsideEvent) - // If the tab or arrow keys are pressed we want to select the next field. This - // is however out of the scope of this component so we emit the selectNext - // event that the GridView can handle. - this.$el.keyPressedNextFieldEvent = (event) => { - // We will first ask if we can select the next field. If that is not allowed - // we don't do anything. - if (!this.$refs.field.canSelectNext(event)) { - return - } - - const { keyCode } = event + // Event that is called when a key is pressed while the field is selected. + this.$el.keyDownEvent = (event) => { + // If the tab or arrow keys are pressed we want to select the next field. This + // is however out of the scope of this component so we emit the selectNext + // event that the GridView can handle. + const { keyCode, ctrlKey, metaKey } = event const arrowKeysMapping = { 37: 'selectPrevious', 38: 'selectAbove', 39: 'selectNext', 40: 'selectBelow', } - if (Object.keys(arrowKeysMapping).includes(keyCode.toString())) { + if ( + Object.keys(arrowKeysMapping).includes(keyCode.toString()) && + this.$refs.field.canSelectNext(event) + ) { event.preventDefault() this.$emit(arrowKeysMapping[keyCode]) } - - if (keyCode === 9) { + if (keyCode === 9 && this.$refs.field.canSelectNext(event)) { event.preventDefault() this.$emit(event.shiftKey ? 'selectPrevious' : 'selectNext') } + + // Copies the value to the clipboard if ctrl/cmd + c is pressed. + if ( + (ctrlKey || metaKey) && + keyCode === 67 && + this.$refs.field.canCopy(event) + ) { + const rawValue = this.row['field_' + this.field.id] + const value = this.$registry + .get('field', this.field.type) + .prepareValueForCopy(this.field, rawValue) + copyToClipboard(value) + } + + // Removes the value if the backspace/delete key is pressed. + if ( + (keyCode === 46 || keyCode === 8) && + this.$refs.field.canEmpty(event) + ) { + event.preventDefault() + const value = this.$registry + .get('field', this.field.type) + .getEmptyValue(this.field) + const oldValue = this.row['field_' + this.field.id] + if (value !== oldValue) { + this.update(value, oldValue) + } + } } - document.body.addEventListener( - 'keydown', - this.$el.keyPressedNextFieldEvent - ) + document.body.addEventListener('keydown', this.$el.keyDownEvent) + + // Updates the value of the field when a user pastes something in the field. + this.$el.pasteEvent = (event) => { + if (!this.$refs.field.canPaste(event)) { + return + } + + const value = this.$registry + .get('field', this.field.type) + .prepareValueForPaste(this.field, event.clipboardData) + const oldValue = this.row['field_' + this.field.id] + if (value !== oldValue) { + this.update(value, oldValue) + } + } + document.addEventListener('paste', this.$el.pasteEvent) // Emit the selected event so that the parent component can take an action like // making sure that the element fits in the viewport. @@ -152,11 +191,10 @@ export default { this.selected = false }) document.body.removeEventListener('click', this.$el.clickOutsideEvent) - document.body.removeEventListener( - 'keydown', - this.$el.keyPressedNextFieldEvent - ) + document.body.removeEventListener('keydown', this.$el.keyDownEvent) + document.removeEventListener('paste', this.$el.pasteEvent) }, }, } </script> +gl diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index efcf75d78..dc1156c1e 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -116,6 +116,24 @@ export class FieldType extends Registerable { toHumanReadableString(field, value) { return value } + + /** + * This hook is called before the field's value is copied to the clipboard. + * Optionally formatting can be done here. By default the value is always + * converted to a string. + */ + prepareValueForCopy(field, value) { + return value.toString() + } + + /** + * This hook is called before the field's value is overwritten by the clipboard + * data. That data might needs to be prepared so that the field accepts it. + * By default the text value if the clipboard data is used. + */ + prepareValueForPaste(field, clipboardData) { + return clipboardData.getData('text') + } } export class TextFieldType extends FieldType { @@ -194,6 +212,36 @@ export class NumberFieldType extends FieldType { getRowEditFieldComponent() { return RowEditFieldNumber } + + /** + * First checks if the value is numeric, if that is the case, the number is going + * to be formatted. + */ + prepareValueForPaste(field, clipboardData) { + const value = clipboardData.getData('text') + if (isNaN(parseFloat(value)) || !isFinite(value)) { + return null + } + return this.constructor.formatNumber(field, value) + } + + /** + * Formats the value based on the field's settings. The number will be rounded + * if to much decimal places are provided and if negative numbers aren't allowed + * they will be set to 0. + */ + static formatNumber(field, value) { + if (value === '' || isNaN(value) || value === undefined || value === null) { + return null + } + const decimalPlaces = + field.number_type === 'DECIMAL' ? field.number_decimal_places : 0 + let number = parseFloat(value) + if (!field.number_negative && number < 0) { + number = 0 + } + return number.toFixed(decimalPlaces) + } } export class BooleanFieldType extends FieldType { @@ -220,4 +268,14 @@ export class BooleanFieldType extends FieldType { getEmptyValue(field) { return false } + + /** + * Check if the clipboard data text contains a string that might indicate if the + * value is true. + */ + prepareValueForPaste(field, clipboardData) { + const value = clipboardData.getData('text').toLowerCase() + const allowed = ['1', 'y', 't', 'y', 'yes', 'true', 'on'] + return allowed.includes(value) + } } diff --git a/web-frontend/modules/database/mixins/gridField.js b/web-frontend/modules/database/mixins/gridField.js index 1b38ad9b0..70cf8a7ee 100644 --- a/web-frontend/modules/database/mixins/gridField.js +++ b/web-frontend/modules/database/mixins/gridField.js @@ -56,5 +56,31 @@ export default { canSelectNext() { return true }, + /** + * If the user presses ctrl/cmd + c while a field is selected, the value is + * going to be copied to the clipboard. In some cases, for example when the user + * is editing the value, we do not want to copy the value. If false is returned + * the value won't be copied. + */ + canCopy() { + return true + }, + /** + * If the user presses ctrl/cmd + v while a field is selected, the value is + * overwritten with the data of the clipboard. In some cases, for example when the + * user is editing the value, we do not want to change the value. If false is + * returned the value won't be changed. + */ + canPaste() { + return true + }, + /** + * If the user presses delete or backspace while a field is selected, the value is + * deleted. In some cases, for example when the user is editing the value, we do + * not want to delete the value. If false is returned the value won't be changed. + */ + canEmpty() { + return true + }, }, } diff --git a/web-frontend/modules/database/mixins/gridFieldInput.js b/web-frontend/modules/database/mixins/gridFieldInput.js index b0c00972b..f00414e35 100644 --- a/web-frontend/modules/database/mixins/gridFieldInput.js +++ b/web-frontend/modules/database/mixins/gridFieldInput.js @@ -163,5 +163,14 @@ export default { canSaveByPressingEnter(event) { return true }, + canCopy() { + return !this.editing + }, + canPaste() { + return !this.editing + }, + canEmpty() { + return !this.editing + }, }, } diff --git a/web-frontend/modules/database/mixins/numberField.js b/web-frontend/modules/database/mixins/numberField.js index 3fc719e8c..eeeb2eb3e 100644 --- a/web-frontend/modules/database/mixins/numberField.js +++ b/web-frontend/modules/database/mixins/numberField.js @@ -1,3 +1,5 @@ +import { NumberFieldType } from '@baserow/modules/database/fieldTypes' + /** * This mixin contains some method overrides for validating and formatting the * number field. This mixin is used in both the GridViewFieldNumber and @@ -21,28 +23,11 @@ export default { return this.getError() === null }, /** - * Formats the value based on the field's settings. The number will be rounded - * if to much decimal places are provided and if negative numbers aren't allowed - * they will be set to 0. + * Before the numeric value is saved we might need to do some formatting such that + * the value is conform the fields requirements. */ beforeSave(value) { - if ( - value === '' || - isNaN(value) || - value === undefined || - value === null - ) { - return null - } - const decimalPlaces = - this.field.number_type === 'DECIMAL' - ? this.field.number_decimal_places - : 0 - let number = parseFloat(value) - if (!this.field.number_negative && number < 0) { - number = 0 - } - return number.toFixed(decimalPlaces) + return NumberFieldType.formatNumber(this.field, value) }, }, } diff --git a/web-frontend/modules/database/utils/clipboard.js b/web-frontend/modules/database/utils/clipboard.js new file mode 100644 index 000000000..f661b8e90 --- /dev/null +++ b/web-frontend/modules/database/utils/clipboard.js @@ -0,0 +1,17 @@ +/** + * Copies the given text to the clipboard by temporarily creating a textarea and + * using the documents `copy` command. + */ +export const copyToClipboard = (text) => { + const textarea = document.createElement('textarea') + document.body.appendChild(textarea) + + textarea.style.position = 'absolute' + textarea.style.left = '-99999px' + textarea.style.top = '-99999px' + textarea.value = text + textarea.select() + + document.execCommand('copy') + document.body.removeChild(textarea) +}