Google Sheets - Prevent users from pasting over cells with data validation - google-apps-script

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??

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://stackoverflow.com/a/36365775/7053599
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.

Related

Apps Script - How could this code be streamlined?

I've recently started working with Apps Script to improve the scope of what my google sheets can do, and I wanted to ask more experienced people how I might make my script more efficient. I used a mixture of tutorials, documentation, and trial & error to make it. I find that although it usually completes the task it's meant for, sometimes it takes an unreasonably long time or exceeds its runtime and simply stops.
I would like to know which best practices I could implement to make it run more quickly overall, and which things I might be able to include in future scripts to avoid any pitfalls I'd landed in here.
Scope:
The script is meant to take each day's new data and apply it to a new sheet called 'TODAY.' It works as follows.
Rename the tab labeled 'TODAY' to the previous workday's date (if today is 2.3, it renames the sheet to 2.2.)
Hide this renamed tab.
Duplicate the 'TEMPLATE' tab, and rename it to 'TODAY.'
Pull data from the 'RAW DATA' tab, and paste it into the new 'TODAY' tab.
Paste a formula into the new 'TODAY' tab and drag it down to the bottom of the table so that the correct values populate and the conditional formatting occurs.
Any help would be greatly appreciated, I really just need some direction for how to improve my work.
Here is a link to an example sheet with editing permissions enabled: https://docs.google.com/spreadsheets/d/1F7bAd2DjKgk53e-haPgjWfFphMfu5YBn8iRQ3qwC3n0/edit?usp=sharing
In my humble opinion, a good Google Sheet App Script doesn't need to use activate to control the source or destination of data. The sheet and script developer should know what and where they want the data to come from and go. Activate is like using the mouse to click on something.
I've taken your script and rewritten to minimize the use of variables. I have only one sheet variable and reuse it throughout. In fact for the majority of the time it is the copy of the TEMPLATE called TODAY.
Also unless I have to use a sheet last row many times, I avoid using a variable and instead just use sheet.getLastRow(). Same for columns.
I always wrap my code in a try catch block as a matter of habit.
As a last note, unless you change the notation in column C and N you could have used your script to fill in column B.
function myDailyUpdate() {
try {
let spread = SpreadsheetApp.getActiveSpreadsheet();
// Step 1
let sheet = spread.getSheetByName("TODAY");
let oldDate = sheet.getRange("Q4").getValue();
let prevDate = Utilities.formatDate(oldDate,"GMT-5","M.d");
// Renames old 'TODAY' sheet to previous workday's date.
sheet.setName(prevDate);
// Sets the color to red.
sheet.setTabColor("990000");
// Hides the old 'TODAY' sheet
sheet.hideSheet();
// Step 2
sheet = spread.getSheetByName("TEMPLATE");
// Copies the contents of the 'TEMPLATE' sheet to a new sheet called 'TODAY.'
sheet = sheet.copyTo(spread);
sheet.setName("TODAY");
sheet.activate(); // required to move to 1st position
// Move TEMPLATE to first position
spread.moveActiveSheet(1);
// Step 3
// Colors the 'TODAY' tab green to signify it being active.
sheet.setTabColor("6aa85f")
// Identifies the 'RAWDATA' sheet for later use.
let source = spread.getSheetByName("RAWDATA");
// Identifies ranges and values for later use.
let values = source.getDataRange().getValues();
// sheet is still the "TODAY" sheet
// Identifies 'TODAY' sheet as the recipient of 'RAWDATA' data, and identifies the range.
// Sets the values from 'RAWDATA' into 'TODAY.'
sheet.getRange(12,2,values.length,values[0].length).setValues(values);
// Step 4
// sheet is still the "TODAY" sheet
let range = sheet.getRange("C12");
range.setFormula(
'=IFERROR(IFERROR(IFS(VLOOKUP($B12,INDIRECT'
+'('
+'"'
+'\''
+'"'
+'&$Q$4&'
+'"'
+'\''
+'!"&"!"&"A1:O2000")'
+',15,false)="D","D",$N12="Quote","Q",$N12="Important","I",$N12="On Hold","H",$N12="IN TRANSIT","T",$N12="REQUEST","R",$N12="INCOMPLETE","N",$N12="COMMENT","C"),VLOOKUP($N12,$B$3:$C$9,2,FALSE)),"")');
// Pastes the above formula into cell C12.
let fillRange = sheet.getRange(12,3,values.length,1);
range.copyTo(fillRange);
sheet.activate();
}
catch(err) {
console.log(err);
}
}

Modify Tinyurl Google Script Function for Sheets

I've been using this function to shorten links (Get range, if length over 200, copy and paste over new tinyurl link in same cell). But I wanna modify it to take active cell or active range instead of input range from UI prompt response. In this case I probably wouldn't need the if(x.length > 200) condition either. I've tried to research for some solutions that I could implement but its too complex for my beginner skills and understanding of original code to modify. Is there an easy fix I could do to it?
function tinyUrl() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var ui = SpreadsheetApp.getUi();
var final = [];
var response = ui.prompt('Enter range:');
if(response.getSelectedButton() == ui.Button.Ok) {
try{
var values = [].concat.apply([], ss.getRange(response.getResponseText()).getValues()).filter(String);
SpreadsheetApp.getActiveSpreadsheet().toast('Processing range: '+ss.getRange(response.getResponseText()).getA1Notation(),'Task Started');
values.forEach(x => {
if(x.length > 200) {
final.push(["IMPORTDATA(concatenate(\"http:// tiny url.com/api-create.php?url="+x+"\"),\"\")"]);
}else{
final.push(["=HYPERLINK(\""+x+"\")"]);
}
})
}catch(e){
SpreadsheetApp.getUi().alert(response.getResponseText()+' is not valid range!');
}
}else{
return;
}
var r = response.getResponseText().split(":");
if(r[0].length=1 &&r[1].length == 1){
ss.getRange(r[0]+"1:"+r[0]+final.lenght).setFormulas(final);
}else{
ss.getRange(r[0]+":"+r[0].slice(0,1)+final.length).setFormulas(final);
}
}
The exact solution depends on your actual application. I think your question is how to detect where to apply your custom function. But let's take a step back.
There are two ways to interact with existing data in sheet and a custom function via AppsScript.
One is that you can directly call your custom function with ranges in the sheet. If the input range is a single cell, the data is read directly. If the input range spans more than a single cell, the data is read as nested lists: a list of columns which are lists of rows. For example, A1:B2 will be read as [[A1, B1], [A2, B2]].
So, for example, you can always call your custom function tinyurl() wherever you input url in your sheet and let your custom function decide when to shorten it. Since your custom function is called in-place, there is no detection issue; UI prompt is not required.
The downside is that if you call your custom function in too many places, there will be delay in getting results. And I don't think the results are always stored with the sheet. (ie. when you open the sheet again, all cells with tinyurl() may refresh and cause delay.)
Second method is via the Range Class which is what you are using. Instead of using UI prompt though, you can add onEdit() trigger to your custom function and let your function either check for currently selected cell via SpreadsheetApp.getActive().getActiveRange() or scan for urls over the sheet where you intend for user inputs to be stored.
The downside of using onEdit() trigger is the UI lag. Relying on actively selected range can also be problematic. Yet if you scan over the whole sheet, well, you have to do that. At the end though, you get to store the resultant URLs permanently with the sheet. So after the initial lag, you won't have delays in the future.
In this route, you may find getLastRow(), getLastColumn() in Sheet and getNextDataCell() in Range convenient. If you choose to process the whole sheet, you may instead use the onOpen() trigger.
I would prefer to store all URLs in one place and use the 1st option. Thus, the custom function is only called once and that's useful for minimizing delay. Other parts of the sheet can reference cells in that centralized range.
The exact solution depends on your actual application.

Google Sheets script that runs based on the face value of a cell while ignoring formulas

I made a Google Sheet to check every media that plays on a certain channel on TV using a lot of workaround formulas within the cells themselves. A part of this sheet is a column (G) that tells me whether or not the specific episode/media/whatever is currently playing, has played in the past or will be played later today/at a later date using a "NOW" function. Next to that column there is another (F) where the user is able to write a "V", and in the case the show is playing but the user hasn't checked it yet, it writes "Check Me" See Example.
I wanted to create a button that will automatically change that "Check Me" into a "V" but the problem is that "Check Me" is based on a simple formula written throughout column F (=IF(G5="Playing","Check Me","")), so when I tried to run a script I found here on StackOverflow:
function Test() {
var sheet = SpreadsheetApp.getActiveSheet();
var range = sheet.getRange("F5:F700");
range.setValues(range.getValues().map(function(row) {
return [row[0].replace(/Check Me/, "V")];
}));
}
(Can't remember the exact thread I got it from and it's been two days since I found it with lots of similar searches in my history, so I apologize for not crediting)
together with its intended use, it also straight up deleted all the rest of the formulas from the column, probably due to the formula itself containing "Check Me" but I might be mistaken.
To be honest, before this week I barely ever worked with either Google Sheets, much less JavaScript or even coding in general, so I'm pretty much restrained to changing values and very minor modifications in scripts I find online.
The only idea I had as to how to solve it is to add an "if IsBlank" but regarding face value of the cell only rather than its contents, but I don't know how to do it or whether it is even possible in the first place. At the very least, google shows no results on the subject. Is there a way to add that function? or perhaps a different method altogether to make that button work? (it's a drawing I will assign a script to)
Because you're using a map function to update the range, you'll need to get the formulas using getFormulas() in addition to the display values using either getValues() or getDisplayValues(). Using only the display values, as you're currently doing, will cause you to lose the formulas when you update the sheet. Conversely, using only the formulas would cause you to lose all of the display values that don't have a formula, so you'll need both. Try this and see if does what you want:
function Test() {
var sheet = SpreadsheetApp.getActiveSheet();
var range = sheet.getRange("F5:F700");
// Get all cell formulas and display values
var formulas = range.getFormulas();
var values = range.getValues();
range.setValues(formulas.map(function(row, index) {
var formula = row[0];
var value = values[index][0];
// Check only the display value
if (value == "Check Me") {
return ["V"];
} else {
// Return formula if it exists, else return value
return [formula || value];
}
}));
}

Using "=TRANSPOSE()" for my column headers, how do I keep data linked when I insert a new row in the original data?

I have one sheet which is my data in which I list my events and their dates. These are then inserted on a separate sheet where volunteers can use checkboxes for their availability. Now if I reorder my events in the data-sheet then the headers in the availability sheet will move, but the availability will not move with them. Any way to fix this?
Here is an example sheet:
https://docs.google.com/spreadsheets/d/1tTVMOCKnLT2dRhKDBMV74LNMYOQvxKv3AA8egHCVG78/edit?usp=sharing
This is a simplified example from my actual problem, but I was wondering if there is a good way to keep this data linked? I would like to be able to re-order the data in the "Events Data" sheet while keeping the correct availability under each event in the "Availability sheet". Currently, one moves and the other one is static.
How I've done this using Google Apps Script in a similar situation (in my case, associating a "memo" column with rows producted by QUERY). Essentially, what we do is synthesize our dynamic display (with QUERY in my case and TRANSPOSE in yours) from static sources (other sheets), and use Google Apps Script to move data entered on the dynamic sheet to the appropriate static sheet, where we can easily retrieve it and render it appropriately.
The only information stored on the dynamic sheet is that used for rendering it (TRANSPOSE, VLOOKUP, whatever works for your situation). When the user edits that sheet, we will take the value of their edit and immediately move it to a static sheet using onEdit(). This information from the static sheet will then be rendered onto the dynamic sheet, regardless of how its rendering changes.
First, create a sheet where you will store the true values for each row.
Second, use an arrayformula vlookup (or hlookup) to search your storage sheet based on a unique identifier from your transposed sheet, e.g.
=ARRAYFORMULA(IFNA(VLOOKUP(A2:A, Storage!A2:B, 2, 0), ""))
Third, using the Script Editor, add the following to your onEdit() function in Google Apps Script:
if (/* use e.range here to check that you're in an appropriate location... */) {
//this section should execute on our 'dynamic' sheet, after the user edits it
var unique_id = SpreadsheetApp.getActiveSheet().getRange(e.range.getRow(), 1).getValue();
// the 'getRange' here is looking from where the user edited to a unique identifier
// associated with this record; this could potentially be built on to reference multiple unique identifiers.
// For instance, this would be the cell with the text, e.g., unique_id = 'Event A'
// and the script could be extended to ALSO pick up, e.g. "edited_person", etc.
var storagesheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Storage');
var findid = storagesheet.createTextFinder(unique_id).findNext();
// this will search your storage sheet to see if there is an existing entry associated with the unique ID
if (findid) {
storagesheet.getRange(findid.getRow(), 2, 1, 2).setValues([[e.value, e.user]]);
// if there is, then we update it ON THE STORAGE SHEET with the new contents of the cell
} else {
storagesheet.appendRow([unique_id, e.value, e.user]);
// otherwise, we create one with the contents of the cell.
//This can likewise be expanded; create more cells, update more sheets, create new sheets, whatever else may be needed
}
e.range.clearContent();
if (e.range.getRow() === 2) { //or wherever your array formula is, again we are assuming this part of the onEdit() should only occur for edits of the dynamic sheet
//making sure we don't lose array formula if the user writes in this field
e.range.setFormula("=ARRAYFORMULA(IFNA(VLOOKUP(A2:A, Storage!A2:B, 2, 0), \"\"))")
}
}
These are based on vertically stored/displayed information, and only store a single field for each unique ID, but you can extrapolate on that pretty easily to match based on both the event and person's value, store multiple values for each event, etc. according to your needs.

Replace INDIRECT() in data validation rule

I had a working Excel spreadsheet that used indirect() in the data validation and it worked fine. I uploaded it to sheets and converted it, now the indirect does not work.
I have found a link on the support forum that explains it does not work in Chrome but appears to work in Firefox, and the answers and workarounds seem to be for generating a secondary list... which is what I want, but in a data validation across a row.
I have knocked up a simple test sheet, hopefully public and the script editor is visible:
https://docs.google.com/spreadsheets/d/1KUgrdXKIKlk1DWvDOX9cY3B2VnRH_5h_vKuZJlqUlN8/edit?usp=sharing
Hopefully you can see what I'm after. I want the validation in C8 to be the list of items in the category based in B8; C9 based on B9 etc.
EDIT and Update
The question is about a replacement to indirect() in a data validation rule. While I did find a way round this by using indirect(), I preferred the version mentioned by Desire (to whom I have attributed the answer), but I thought I'd document my solution in case the sheet above becomes unavailable, or you cannot access it, or you just wanted a bit more detail.
So, for My Demo I have this:
In A1:C5 are my lists of data with the titles.
In the range B8:B12 I applied a data validation rule of value in range of A1:C1 - this gives the first dropdown.
In Cell E8 I put the formula =transpose(filter($A$2:$C$5, $A$1:$C$1 = B8)) and then copied this down to E12
Finally I put the following in a function and ran it in the script editor.
function runMeOnce() {
var dst = SpreadsheetApp.getActive().getSheetByName('Sheet1').getRange('C8:C12');
var rules = [];
for (var i = 8; i < 13; i++) {
var src = SpreadsheetApp.getActive().getSheetByName('Sheet1').getRange("E" + i + ":H" + i);
var rule = SpreadsheetApp.newDataValidation().requireValueInRange(src).build();
rules.push(rule);
}
dst.setDataValidations(rules);
}
That's all there is, no more onEdit() triggering.
NOTE There is one downside I bumped into with this method though. I have this in place for 6000+ rows in my actual spreadsheet, and across multiple sheets, with some dropdowns having 50-100 items in. This solution seriously eats into the (current) 2 million cell limit.
Hope this helps someone.
Data Validation rule of the type "List of items" takes only a comma-separated list of values as its parameter, and does not evaluate any formulas you try to put there. It does not matter what the function returns, because it will not be called. If you put, say "=sqrt(A10)" in the field "List of items", that only means that the validation rule will require the string "=sqrt(A10)" to be entered in the cell.
Similarly with "List from a Range". Either what you enter parses as range notation, or it does not. The string "=getValidationRange(B8)" does not parse as range notation, hence the error. The function is never called.
The only type of validation that calls a function is "Custom formula". If you use it, then the validation can be performed as intended: for example,
=match(C8, filter(A2:C5, A1:C1 = B8), 0)
requires the content of C8 to be in the column of the table A2:C5 under the heading that matches the category in B8. However, with a custom formula you do not get a dropdown in a cell.
To get a dynamic dropdown, one can either
Use an auxiliary range
For example, enter filter(A2:C5, A1:C1 = B8) in cell F1, so that the F column is for the categories currently selected. The data validation would be "List from a Range", F1:F. This is a fine workaround for one validation rule, but takes more work when you have multiple ones.
Use a triggered script
Use a script that is triggered on edit and sets data validation rules accordingly; this is discussed in How do you do dynamic / dependent drop downs in Google Sheets? among other places.
Based on the sacrificing a goat issue, I did find a simple(ish) way around the problem that still uses indirect().
Set up the named ranges as previously using the titles in CamelCase. In my example I have CatA, CatB, and CatC - i.e. the white space needs removing.
At the end of a row (or in another sheet) transpose the chosen named range (in cell E8: =transpose(indirect(substitute(B8, " ", ""))) copy this down as far as you need.
At this point it's good to note that because we are unsing builtin functions, the speed is so much better, as can be seen by my example.
Now the painful bit. For each subcategory cell (C8, C9 etc in my example), you need to add the validation independently as a range of E8:ZZ8 (obviously ZZ8 needs reigning in a bit) and E9:ZZ9 etc. It doesn't seem to do referential so if you select all the cell in the column, they all only look at the data you specifically type in the box... I might just not have worked out R1C1 notation here, however. I tried.
This can be scripted on GAS to create the R1C1 validation function and then apply it to the range.