diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index cb67c000..61f4c75d 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -327,59 +327,6 @@ BaseView.prototype.insertRow = function(index) { }); }; -/** - * Given a 2-d paste column-oriented paste data and target cols, transform the data to omit - * fields that shouldn't be pasted over and extract rich paste data if available. - * @param {Array>} data - Column-oriented 2-d array of either - * plain strings or rich paste data returned by `tableUtil.parsePasteHtml` with `displayValue` - * and, optionally, `colType` and `rawValue` attributes. - * @param {Array} cols - Array of target column objects - * @returns {Object} - Object mapping colId to array of column values, suitable for use in Bulk - * actions. - */ -BaseView.prototype._parsePasteForView = function(data, fields) { - const updateCols = fields.map(field => { - const col = field && field.column(); - if (col && !col.isRealFormula() && !col.disableEditData()) { - return col; - } else { - return null; // Don't include formulas and missing columns - } - }); - const updateColIds = updateCols.map(c => c && c.colId()); - const updateColTypes = updateCols.map(c => c && c.type()); - const parsers = fields.map(field => field && field.createValueParser() || (x => x)); - const docIdHash = tableUtil.getDocIdHash(); - - const richData = data.map((col, idx) => { - if (!col.length) { - return col; - } - const typeMatches = col[0] && col[0].colType === updateColTypes[idx] && ( - // When copying references, only use the row ID (raw value) when copying within the same document - // to avoid referencing the wrong rows. - col[0].docIdHash === docIdHash || !gristTypes.isFullReferencingType(updateColTypes[idx]) - ); - const parser = parsers[idx]; - return col.map(v => { - if (v) { - if (typeMatches && v.hasOwnProperty('rawValue')) { - return v.rawValue; - } - if (v.hasOwnProperty('displayValue')) { - return parser(v.displayValue); - } - if (typeof v === "string") { - return parser(v); - } - } - return v; - }); - }); - - return _.omit(_.object(updateColIds, richData), null); -}; - BaseView.prototype._getDefaultColValues = function() { const linkingState = this.viewSection.linkingState.peek(); if (!linkingState) { diff --git a/app/client/components/BaseView2.ts b/app/client/components/BaseView2.ts new file mode 100644 index 00000000..151d06ca --- /dev/null +++ b/app/client/components/BaseView2.ts @@ -0,0 +1,89 @@ +/** + * This file contains logic moved from BaseView.js and ported to TS. + */ + +import {GristDoc} from 'app/client/components/GristDoc'; +import {getDocIdHash, RichPasteObject} from 'app/client/lib/tableUtil'; +import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec'; +import {UserAction} from 'app/common/DocActions'; +import {isFullReferencingType} from 'app/common/gristTypes'; +import {SchemaTypes} from 'app/common/schema'; +import {BulkColValues} from 'app/plugin/GristData'; +import omit = require('lodash/omit'); + +/** + * Given a 2-d paste column-oriented paste data and target cols, transform the data to omit + * fields that shouldn't be pasted over and extract rich paste data if available. + * When pasting into empty columns, also update them with options from the source column. + * `data` is a column-oriented 2-d array of either + * plain strings or rich paste data returned by `tableUtil.parsePasteHtml`. + * `fields` are the target fields being pasted into. + */ +export async function parsePasteForView( + data: Array[], fields: ViewFieldRec[], gristDoc: GristDoc +): Promise { + const result: BulkColValues = {}; + const actions: UserAction[] = []; + const thisDocIdHash = getDocIdHash(); + + data.forEach((col, idx) => { + const field = fields[idx]; + const colRec = field?.column(); + if (!colRec || colRec.isRealFormula() || colRec.disableEditData()) { + return; + } + + const parser = field.createValueParser() || (x => x); + let typeMatches = false; + if (col[0] && typeof col[0] === "object") { + const {colType, docIdHash, colRef} = col[0]; + const targetType = colRec.type(); + const docIdMatches = docIdHash === thisDocIdHash; + typeMatches = docIdMatches || !isFullReferencingType(colType || ""); + + if (targetType !== "Any") { + typeMatches = typeMatches && colType === targetType; + } else if (docIdMatches && colRef) { + // Try copying source column type and options into empty columns + const sourceColRec = gristDoc.docModel.columns.getRowModel(colRef); + const sourceType = sourceColRec.type(); + // Check that the source column still exists, has a type other than Text, and the type hasn't changed. + // For Text columns, we don't copy over column info so that type guessing can still happen. + if (sourceColRec.getRowId() && sourceType !== "Text" && sourceType === colType) { + const colInfo: Partial = { + type: sourceType, + visibleCol: sourceColRec.visibleCol(), + // Conditional formatting rules are not copied right now, that's a bit more complicated + // and copying the formula may or may not be desirable. + widgetOptions: JSON.stringify(omit(sourceColRec.widgetOptionsJson(), "rulesOptions")), + }; + actions.push( + ["UpdateRecord", "_grist_Tables_column", colRec.getRowId(), colInfo], + ["MaybeCopyDisplayFormula", colRef, colRec.getRowId()], + ); + } + } + } + + result[colRec.colId()] = col.map(v => { + if (v) { + if (typeof v === "string") { + return parser(v); + } + if (typeMatches && v.hasOwnProperty('rawValue')) { + return v.rawValue; + } + if (v.hasOwnProperty('displayValue')) { + return parser(v.displayValue); + } + } + return v; + }); + }); + + if (actions.length) { + await gristDoc.docData.sendActions(actions); + } + + return result; +} diff --git a/app/client/components/CopySelection.ts b/app/client/components/CopySelection.ts index 393fae5c..87b5692f 100644 --- a/app/client/components/CopySelection.ts +++ b/app/client/components/CopySelection.ts @@ -14,6 +14,7 @@ import type {UIRowId} from 'app/common/UIRowId'; */ export class CopySelection { public readonly colIds = this.fields.map(f => f.colId()); + public readonly colRefs = this.fields.map(f => f.colRef()); public readonly displayColIds = this.fields.map(f => f.displayColModel().colId()); public readonly rowStyle: {[r: number]: object}|undefined; public readonly colStyle: {[c: string]: object}|undefined; diff --git a/app/client/components/DetailView.js b/app/client/components/DetailView.js index b28b5c4c..a21fe2b3 100644 --- a/app/client/components/DetailView.js +++ b/app/client/components/DetailView.js @@ -14,6 +14,7 @@ var {CopySelection} = require('./CopySelection'); var RecordLayout = require('./RecordLayout'); var commands = require('./commands'); const {RowContextMenu} = require('../ui/RowContextMenu'); +const {parsePasteForView} = require("./BaseView2"); /** * DetailView component implements a list of record layouts. @@ -131,7 +132,9 @@ DetailView.generalCommands = { copy: function() { return this.copy(this.getSelection()); }, cut: function() { return this.cut(this.getSelection()); }, - paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); }, + paste: function(pasteObj, cutCallback) { + return this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback)); + }, editLayout: function() { if (this.scrolly()) { @@ -166,12 +169,12 @@ DetailView.prototype.deleteRow = function(index) { * @param {Function} cutCallback - If provided returns the record removal action needed * for a cut. */ -DetailView.prototype.paste = function(data, cutCallback) { +DetailView.prototype.paste = async function(data, cutCallback) { let pasteData = data[0][0]; let field = this.viewSection.viewFields().at(this.cursor.fieldIndex()); let isCompletePaste = (data.length === 1 && data[0].length === 1); - let richData = this._parsePasteForView([[pasteData]], [field]); + const richData = await parsePasteForView([[pasteData]], [field], this.gristDoc); if (_.isEmpty(richData)) { return; } diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 97dfee7f..1a80615d 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -40,6 +40,7 @@ const {testId} = require('app/client/ui2018/cssVars'); const {contextMenu} = require('app/client/ui/contextMenu'); const {menuToggle} = require('app/client/ui/MenuToggle'); const {showTooltip} = require('app/client/ui/tooltips'); +const {parsePasteForView} = require("./BaseView2"); // A threshold for interpreting a motionless click as a click rather than a drag. @@ -309,7 +310,7 @@ GridView.gridCommands = { copy: function() { return this.copy(this.getSelection()); }, cut: function() { return this.cut(this.getSelection()); }, paste: async function(pasteObj, cutCallback) { - await this.paste(pasteObj, cutCallback); + await this.gristDoc.docData.bundleActions(null, () => this.paste(pasteObj, cutCallback)); await this.scrollToCursor(false); }, sortAsc: function() { @@ -381,7 +382,7 @@ GridView.prototype._shiftSelect = function(step, selectObs, exemptType, maxVal) * @param {Function} cutCallback - If provided returns the record removal action needed for * a cut. */ -GridView.prototype.paste = function(data, cutCallback) { +GridView.prototype.paste = async function(data, cutCallback) { // TODO: If pasting into columns by which this view is sorted, rows may jump. It is still better // to allow it, but we should "freeze" the affected rows to prevent them from jumping, until the // user re-applies the sort manually. (This is a particularly bad experience when rows get @@ -410,7 +411,7 @@ GridView.prototype.paste = function(data, cutCallback) { let fields = this.viewSection.viewFields().peek(); let pasteFields = updateColIndices.map(i => fields[i] || null); - let richData = this._parsePasteForView(pasteData, pasteFields); + const richData = await parsePasteForView(pasteData, pasteFields, this.gristDoc); let actions = this._createBulkActionsFromPaste(updateRowIds, richData); if (actions.length > 0) { diff --git a/app/client/lib/tableUtil.ts b/app/client/lib/tableUtil.ts index a48c7b2c..e7453f59 100644 --- a/app/client/lib/tableUtil.ts +++ b/app/client/lib/tableUtil.ts @@ -102,9 +102,10 @@ export function makePasteHtml(tableData: TableData, selection: CopySelection, in const elem = dom('table', {border: '1', cellspacing: '0', style: 'white-space: pre', 'data-grist-doc-id-hash': getDocIdHash()}, - dom('colgroup', selection.colIds.map(colId => + dom('colgroup', selection.colIds.map((colId, idx) => dom('col', { style: _styleAttr(colStyle[colId]), + 'data-grist-col-ref': String(selection.colRefs[idx]), 'data-grist-col-type': tableData.getColType(colId) }) )), @@ -134,6 +135,7 @@ export interface RichPasteObject { displayValue: string; docIdHash?: string|null; colType?: string|null; // Column type of the source column. + colRef?: number|null; rawValue?: unknown; // Optional rawValue that should be used if colType matches destination. } @@ -145,20 +147,17 @@ export interface RichPasteObject { export function parsePasteHtml(data: string): RichPasteObject[][] { const parser = new G.DOMParser() as DOMParser; const doc = parser.parseFromString(data, 'text/html'); - const table = doc.querySelector('table'); - const docIdHash = table?.getAttribute('data-grist-doc-id-hash'); + const table = doc.querySelector('table')!; + const docIdHash = table.getAttribute('data-grist-doc-id-hash'); - const colTypes = Array.from(table!.querySelectorAll('col'), col => - col.getAttribute('data-grist-col-type')); - - const result = Array.from(table!.querySelectorAll('tr'), (row, rowIdx) => + const cols = [...table.querySelectorAll('col')]; + const rows = [...table.querySelectorAll('tr')]; + const result = rows.map(row => Array.from(row.querySelectorAll('td, th'), (cell, colIdx) => { - const o: RichPasteObject = {displayValue: cell.textContent!, docIdHash}; - - // If there's a column type, add it to the object - if (colTypes[colIdx]) { - o.colType = colTypes[colIdx]; - } + const col = cols[colIdx]; + const colType = col?.getAttribute('data-grist-col-type'); + const colRef = col && Number(col.getAttribute('data-grist-col-ref')); + const o: RichPasteObject = {displayValue: cell.textContent!, docIdHash, colType, colRef}; if (cell.hasAttribute('data-grist-raw-value')) { o.rawValue = safeJsonParse(cell.getAttribute('data-grist-raw-value')!, diff --git a/sandbox/grist/useractions.py b/sandbox/grist/useractions.py index 1d67c72d..6d04084d 100644 --- a/sandbox/grist/useractions.py +++ b/sandbox/grist/useractions.py @@ -1461,6 +1461,11 @@ class UserActions(object): self._do_doc_action(actions.BulkUpdateRecord(table_id, changed_rows, {dst_col_id: changed_values})) + @useraction + def MaybeCopyDisplayFormula(self, src_col_ref, dst_col_ref): + src_col = self._docmodel.columns.table.get_record(src_col_ref) + dst_col = self._docmodel.columns.table.get_record(dst_col_ref) + self.maybe_copy_display_formula(src_col, dst_col) def maybe_copy_display_formula(self, src_col, dst_col): """