mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	(core) Copy column type and options when pasting into an empty column
Summary: Adds a `data-grist-col-ref` attribute to the copied HTML, then uses that when pasting to look up the source column and retrieve info about it. Copies the info into the target column if: - The document is the same (the docId hash matches) - The source column still exists and has the same type as when copied - The source type isn't Text, because in that case it's nice if type guessing still happens - The target column is empty, meaning it has type Any (we check earlier that it's not a formula column) The info copied is the type, widgetOptions, and reference column settings (visible and display columns) but not conditional formatting. The changes are mostly in a function `parsePasteForView` which is based on `BaseView._parsePasteForView` but ported to TypeScript in a new file `BaseView2.ts`. Added a useraction `MaybeCopyDisplayFormula` exposing an existing Python function `maybe_copy_display_formula` because the target column needs a slightly different display formula. Test Plan: Added a new nbrowser test file and fixture doc. Reviewers: cyprien Reviewed By: cyprien Subscribers: jarek, dsagal Differential Revision: https://phab.getgrist.com/D3344
This commit is contained in:
		
							parent
							
								
									6305811ca6
								
							
						
					
					
						commit
						bf271c822b
					
				@ -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<Array<(RichPasteObject|string)>>} 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<MetaRowModel>} 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) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										89
									
								
								app/client/components/BaseView2.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								app/client/components/BaseView2.ts
									
									
									
									
									
										Normal file
									
								
							@ -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<string | RichPasteObject>[], fields: ViewFieldRec[], gristDoc: GristDoc
 | 
			
		||||
): Promise<BulkColValues> {
 | 
			
		||||
  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<SchemaTypes["_grist_Tables_column"]> = {
 | 
			
		||||
            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;
 | 
			
		||||
}
 | 
			
		||||
@ -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;
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -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) {
 | 
			
		||||
 | 
			
		||||
@ -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')!,
 | 
			
		||||
 | 
			
		||||
@ -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):
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user