Google Sheets - Prevent users from pasting over cells with data validation
Asked Answered
T

2

10

Is it possible to prevent a user from pasting in cells that have drop-down options based on data validation?

While it could be a convenience, when one cell (with data validation) is copied and pasted to another cell (with its own validation) it rewrites the data validation to match range being pasted, if that makes sense.

Alternatively, perhaps there is a script that will accept the info being pasted but rewrite that data validation to its original range??

Threedimensional answered 21/9, 2017 at 21:30 Comment(4)
Doesn't reject input work? And what have you tried so far?Elbring
Wow. Downvoted for simply asking a useful question that I have not seen an answer to yet. Thank you so much. And here I thought this was the place to turn to for answers.Threedimensional
I seem to recall a previous questioner that had a similar problem and to the best of my recollection it was never resolved. Perhaps you should report it as an issue.Interpellation
Cooper, it's great to see you helping out GAS users again! You may actually be referring to the question that you helped me out with: #44663476 I will write an answer to explain how I finally worked this out after a month.Proserpina
P
6

This may be very tricky to pull off depending on your workbook usage, and is more complex than it sounds. In the old days, GAS had ScriptDB so developers would revert this kind of thing by recreating the "UNDO" functionality. When that was sunset, one recommendation was to have a duplicate sheet, and making sure that it always stays aligned with the user's active sheet - then, when the user messes up your data validations, you can just scan all data validation cells and revert those from the duplicate sheet (https://developers.google.com/apps-script/reference/spreadsheet/data-validation-builder). In any case, I concluded that Google Sheets was not made for this specific type of client-facing solutions (this is where Google Forms is used), so if you cannot make it work using "Protected Sheets and Ranges" you will probably end up implementing a hack, as follows.


Here was my hack approach; I was unable to prevent this from happening, however, I was able to auto-revert cells to proper format by using onEdit(), and reverting all damaged named cells after each user edit. The idea is to define rules inside names of named ranges.

(1) You can create a named range for every cell that you want to protect. The name of the named range must encapsulate your data validation rules. For example, if you have a dropdown cell at [B28 on a sheet with ID "1380712296"], that feeds from [range A11-A14 (4 items in the dropdown) on sheet with ID "936278947"], you can name the dropdown cell as follows:

z_1380712296_r?936278947?A11.A14_B28

Or, in a generic form:

z_ DROPDOWN-SHEET-ID _ DATA-VALIDATION-TYPE ? DATA-VALIDATION-SOURCE-SHEET-ID ? DATA-VALIDATION-START-CELL . DATA-VALIDATION-END-CELL _ DROPDOWN-CELL-RANGE-IN-A1-FORMAT

(2) Write a trigger to execute on every user edit, as follows:

function onEdit(e) {
  try {
    autocorrectFormat(e.range);
  }
  catch (e) {}
}

function autocorrectFormat(modifiedRange) {
  // Get named ranges on active sheet
  var sheetNamedRanges = SpreadsheetApp.getActiveSheet().getNamedRanges();

  // Fix active cells that intersect
  for (var i in sheetNamedRanges) {
    // we only go through the named ranges that begin with z_ since others may be declared by users
    if (sheetNamedRanges[i].getName().substring(0,2) == "z_") {
      // This rangesIntersect function below is necessary because onEdit's e.range is often wrong when pasting groups of cells
      // so we want to check if the edited range intersects with a named range's range rather than equals exactly
      if (rangesIntersect(sheetNamedRanges[i].getRange(), modifiedRange)) {
        // Here parse the information on the named range's name and fix the potentially damaged cell using the data 
        // validation rules pulled from the named range's name
        var currentCellInformation = [sheetNamedRanges[i].getRange(),sheetNamedRanges[i].getName().split("_")];
        // Parsing
        var part_1 = currentCellInformation[1][2].charAt(0);
        var part_2 = currentCellInformation[1][2].split("?");  
        // Function to rebuild the dropdown cell
        fixRange(...);
      }
    }
  }
  return;
}

// https://mcmap.net/q/1167448/-google-app-script-check-if-range1-intersects-range2
function rangesIntersect(R1, R2) {
  return (R1.getLastRow() >= R2.getRow()) && (R2.getLastRow() >= R1.getRow()) && (R1.getLastColumn() >= R2.getColumn()) && (R2.getLastColumn() >= R1.getColumn());
}

As you noticed, pasting overwrites the former data validation rules. You may also want to assess the effects of Ctrl+X, Drag+Drop, and Ctrl+\ and their effects on named ranges, since, for example, Ctrl+X moves the named range as well, and Ctrl+\ never executes onEdit().

I included the code for you to get familiar with the kinds functions you would be using - you may also be using DataValidationBuilder in your fixRange() function where you rebuild the dropdown cell.


In response to "Alternatively, perhaps there is a script that will accept the info being pasted but rewrite that data validation to its original range??":

This is a good idea, it had occurred to me as well, but it is not possible to intercept information being pasted with GAS. The closest we have is onEdit(). This is why I mentioned above that I could not prevent it, but rather attempt to auto-revert damaged cells.

Proserpina answered 22/9, 2017 at 1:57 Comment(4)
Thank you very much for the detailed answer. It will take me a bit to wrap my head around it and attempt to implement it. I have much to learn when it comes to programming in general. If interested, here is the sheet that I'll be attempting to implement your suggestions on... docs.google.com/spreadsheets/d/…Threedimensional
You're welcome! It took me some effort to figure this out a while back, so please feel free to ask any questions. I took a look at the workbook and noticed that you have single-celled data validation rules - this makes it easier than dealing with this issue with merged cells. It is also good that you have allowed only certain cells to be modified and by default protected the entire sheet. I had done the same and it works. Good luck!Proserpina
Also, it is clear that you are attempting to make client-facing Google Sheets - which is hard since it was not meant for this. I had no choice since my client insisted on Google Sheets, but if you have freedom in how you do things, you may want to use Google Forms (responses can feed into that same Google Spreadsheet) or you could create a free Firebase website to collect user responses (you can query the Firebase database using a Google Apps Script library). That would take away the stress of having users break your sheets.Proserpina
Good to know. Thanks. I have complete freedom as to how I do things but lack knowledge lol.. although the desire to learn is there. I was thrilled to be able to make those dropdowns dynamic based on the previous selection haha. Dang rookies. I've yet to investigate how useful Google forms really are and know absolutely nothing about Firebase, yet.Threedimensional
B
0

You may try this solution, not perfect one, but requires less settings:

const keepDropDownConfig = {
  sheets: {
   "Sheet1": 2,
   "Sheet2": 4,
   "Sheet4": 3
   // add more in fomat: "Sheet": <first row with data>
  }
}

/**
 * onEdit
 * 
 * Trigger OnEditEvent for Google Sheets
 * https://developers.google.com/apps-script/guides/triggers/OnEditEvents
 * 
 * @typedef {Object} OnEditEvent
 * @property {SpreadsheetApp.Range} range
 * @property {SpreadsheetApp.Spreadsheet} source
 * @property {*} oldValue
 */

/**
 * @param {OnEditEvent} e
 */
function onEdit(e) {

  const sheet = e.range.getSheet();
  const config = keepDropDownConfig;

  const rowFrom = config.sheets[sheet.getName()];
  if (!rowFrom) {
    console.log('do not check this sheet');
    return;
  }

  let numRow = e.range.getRow();
  if (numRow < rowFrom) {
    console.log('Do not check this row');
    return;
  }

  var rowCheck = numRow + 1;
  if (rowCheck > sheet.getLastRow()) {
    rowCheck = numRow - 1;
  }
  if (rowCheck < rowFrom) {
    console.log('nothing to compare with');
    return;
  }
  var numRows = e.range.getHeight();
  var numColumns = e.range.getWidth();

  let fixTasks = [], fixTask;
  const rowOffset = rowCheck - numRow + numRows - 1;
  for (var i = 0; i < numColumns; i++) {
    fixTask = getFixValidationTask_(e, rowOffset, numRows, i);
    if (fixTask) {
      fixTasks.push(fixTask);
    }
    fixTask = null;
  }

  if (!fixTasks.length) {
    console.log('Nothing to fix, clear');
    return
  }

  Browser.msgBox(
    '⚠️Warning', 
    'You tried to overwrite data validation.\\n' +
    'The script will fix this.' +
    '\\n\\nPlease restore original values', 
    Browser.Buttons.OK);

  fixTasks.forEach(fixBrokenDataValidation_);

}

/**
 * @typedef {Object} DVFixTask
 * @property {DataValidation} validation
 * @property {SpreadsheetApp.Range} range
 * @property {*} replaceValue
 */

/**
 * @param {DVFixTask} task
 */
function fixBrokenDataValidation_(task) {
  task.range.setDataValidation(task.validation);
  task.range.setValue(task.replaceValue);
}


/**
 * @param {OnEditEvent} e
 * @param {Number} numColumn
 * 
 * @returns {DVFixTask}
 */
function getFixValidationTask_(e, rowOffset, numRows, columnOffset) {
  const rangeIn = e.range;
  var range = rangeIn.offset(0,columnOffset,numRows,1);
  let validation = rangeIn.offset(0,columnOffset,1,1).getDataValidation();
  var cellCheck = rangeIn.offset(rowOffset, columnOffset, 1, 1);
  var validationCheck = cellCheck.getDataValidation();
  if (!validationCheck) {
    console.log('no validation in check range');
    return null;
  }

  if (validationsAreEqual_(validation, validationCheck)) {
    console.log('all right, validations are same');
    return null;
  }

  let replaceValue = e.oldValue;

  if(replaceValue === undefined) {
    replaceValue = '';
  }

  /** @type DVFixTask */
  const result = {
    range,
    replaceValue,
    validation: validationCheck
  }
  return result;
}

/**
 * @apram {DataValidation} validation1
 * @apram {DataValidation} validation2
 * 
 * @returns {Boolean}
 */
function validationsAreEqual_(validation1, validation2) {
  if (!validation1) {
    return false;
  }
  if (!validation2) {
    return false;
  }
  if (validation1.getCriteriaType() !== validation2.getCriteriaType()) {
    return false;
  }
  return true;
}

Source:

Bristle answered 20/10, 2023 at 11:59 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.