How to set timer for a row in google sheets - google-chrome

I would like to set a timer for an entire row in google sheets where a user can start entering data in the second row only after a certain time after starting row one.
Example: If a user starts filling cells in row 1 then they should be able to fill the data in the second only after the timer ends.
Could anyone suggest me how to get started or suggest me a chrome extension for this use?
You could also suggest me on how to build the chrome extension I can try it along with my colleagues.

This function uses an onEdit trigger to impose a 20 second delay between editing rows. It may not be exactly what you want but perhaps it's a start. It uses PropertiesService to keep state. I think user properties would be a better choice but script properties are easier to develop with since you can modify them directly in the script editor.
function onEdit(e) {
const sh=e.range.getSheet();
const delay=20000;
let ms=Number(new Date().valueOf()).toFixed();
if(sh.getName()=='Sheet10') {
const ps=PropertiesService.getScriptProperties();
let dObj=ps.getProperties();
if(dObj.hasOwnProperty('row') && dObj.hasOwnProperty('delay')) {
if(dObj.row!=e.range.rowStart && Number(ms-dObj.delay)<delay) {
e.range.setValue(e.oldValue);
e.source.toast('Sorry you have ' + (delay-Number(ms-dObj.delay))/1000 + ' seconds left.');
}else{
ps.setProperties({'row':e.range.rowStart,'delay':ms});
}
}else{
ps.setProperties({'row':e.range.rowStart,'delay':ms});
}
}
}

Issue with Protections:
Class Protection is commonly used to protect ranges from being edited. It is not appropriate for your situation, though, because, as specified here, users who are executing the script cannot remove themselves from the list of editors:
Neither the owner of the spreadsheet nor the current user can be removed.
Using oldValue:
Because of this, the best way to go would be to use the parameter oldValue from the onEdit event object.
An onEdit trigger runs every time a user edits a cell. In it, you can use:
PropertiesService to store useful information: (1) whether it is the first time row 1 is edited (isNotFirstTime), and (2) when was last time first row was edited (startTime).
Event object to get information on the edited cell (its row, its old value, etc.).
You can do something along the following lines (check comments):
function onEdit(e) {
var current = new Date(); // Current date
var range = e.range;
var editedRow = range.getRow();
var sheet = range.getSheet();
var props = PropertiesService.getScriptProperties();
var waitingTime = 20 * 1000; // 20 seconds
var isNotFirstTime = props.getProperty("isNotFirstTime"); // Check if first row was previously edited
var startTime = new Date(props.getProperty("startTime")); // Time when first row was first edited
if (editedRow === 1 && !isNotFirstTime) { // Check that (1) edited row is first one, (2) it was not edited before
props.setProperty("startTime", current.toString()); // If it's first time first row was edited, store current time
Utilities.sleep(waitingTime); // Wait for 20 seconds
props.setProperty("isNotFirstTime", true); // Store: first row was previously edited
}
// Check that (1) second row edited, (2) Less than 20 seconds passed since first time first row was edited:
if (editedRow === 2 && (current - startTime) < waitingTime) {
range.setValue(e.oldValue || ""); // Set previous value to edited cell (this avoids editing cells)
}
}
Reference:
onEdit(e)
onEdit Event object
Class PropertiesService

Related

Static timestamping in Google Sheets

I am trying to add STATIC timestamp to my data whenever it is imported or pasted in the sheets.
I am using this formula now
(=ARRAYFORMULA( IFS(I1:I="","",L1:L="",NOW(),TRUE,L1:L)))
but, whenever I open the sheet again the time gets changed automatically to the current time as i am using the now() function. I tried on-Edit in the script, but it's only working when the data is manually entered.
Is there any other way I can use to static timestamp when data is being pasted or imported?
Instead of NOW() on the formula, do it via script using new Date().
The NOW() function updates the timestamp every time the spreadsheet is open or something changes in it, while the new Date() gives you a full (date and time) and static timestamp.
Also, as I've seen on the comments of your question, there really is no way to use onEdit() through automated scripts and macros.
Answer
You can use a custom function to return the actual date with the method new Date() and the Properties Service. Open Apps Script and paste the following function:
Code
function getTimestamp(reset) {
// update the timestamp
if (reset == 1) {
setTime()
}
// try-catch structure in order to set the time in the first execution
try {
var time = ScriptProperties.getProperty('time')
}
catch (err) {
setTime()
var time = ScriptProperties.getProperty('time')
}
return time
}
function setTime() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var time = new Date()
ScriptProperties.setProperty('time', time)
}
How it works
Now, you can use it in any cell like another Sheet function. Call the function with =getTimestamp(0). On the first execution, it tries to get the saved property time, but as the property does not exist it generates a timestamp and saves a new property in the project with the key time and the value of the timestamp.
In the following executions, the value obtained by the function when it is recalculated is the same, since the property is not overwritten unless the function is called with a 1 input: =getTimestamp(1). In this case, the timestamp is updated, but if it is not set back to =getTimestamp(0), every time the function is recalculated (which happens automatically every so often) the timestamp will change.
In conclusion, always use =getTimestamp(0). When you want to update the value, change it to =getTimestamp(1) and go back to the original formula.
update
I have updated the answer to explain how to update the timestamp when new values are added:
Use a cell as input to the function, e.g. =getTimeStamp(A1) 2.
Create an onEdit trigger
Check that the range of the e event belongs to new values.
Update the value of A1 to 1 and then to 0 if you have detected new values.
example:
function onEdit(e){
var range = e.range
var cell = SpreadsheetApp.getActiveSpreadsheet().getRange('A4')
if (range.columnStart > 1 && range.rowStart > 10){
cell.setValue(1)
SpreadsheetApp.flush()
cell.setValue(0)
}
}
If new values are added from column 1 and row 10, A1 is updated to 1 and then to 0, thus updating the value of the timeStamp function and saving it permanently until the trigger is executed again.
References:
Custom Functions in Google Sheets
Working with Dates and Times
Apps Script: Extending Google Sheets
Properties Service
Not sure have your question got a solution. I had the same struggle as yours over the year, especially with pasted data, and I found a solution that works for my case nicely (but not by formula, need to run in Apps Script).
Some background for my case:
I have multiple sheets in the spreadsheet to run and generate the
timestamp
I want to skip my first sheet without running to generate timestamp
in it
I want every edit, even if each value that I paste from Excel to
generate timestamp
I want the timestamp to be individual, each row have their own
timestamp precise to every second
I don't want a total refresh of the entire sheet timestamp when I am
editing any other row
I have a column that is a MUST FILL value to justify whether the
timestamp needs to be generated for that particular row
I want to specify my timestamp on a dedicated column only
function timestamp() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const totalSheet = ss.getSheets();
for (let a=1; a<totalSheet.length; a++) {
let sheet = ss.getSheets()[a];
let range = sheet.getDataRange();
let values = range.getValues();
function autoCount() {
let rowCount;
for (let i = 0; i < values.length; i++) {
rowCount = i
if (values[i][0] === '') {
break;
}
}
return rowCount
}
rowNum = autoCount()
for(let j=1; j<rowNum+1; j++){
if (sheet.getRange(j+1,7).getValue() === '') {
sheet.getRange(j+1,7).setValue(new Date()).setNumberFormat("yyyy-MM-dd hh:mm:ss");
}
}
}
}
Explanation
First, I made a const totalSheet with getSheets() and run it
with a for loop. That is to identify the total number of sheets
inside that spreadsheet. Take note, in here, I made let a=1;
supposed all JavaScript the same, starts with 0, value 1 is to
skip the first sheet and run on the second sheet onwards
then, you will notice a function let sheet = ss.getSheets()[a]
inside the loop. Take note, it is not supposed to use const if
your value inside the variable is constantly changing, so use
let instead will work fine.
then, you will see a function autoCount(). That is to make a for
loop to count the number of rows that have values edited in it. The
if (values[i][0] === '') is to navigate the script to search
through the entire sheet that has value, looking at the row i and
the column 0. Here, the 0 is indicating the first column of the
sheet, and the i is the row of the sheet. Yes, it works like a
json object with panda feeling.
then, you found the number of rows that are edited by running the
autoCount(). Give it a rowNum variable to contain the result.
then, pass that rowNum into a new for loop, and use if (sheeet.getRange(j+1,7).getValue() === '') to determine which row
has not been edited with timestamp. Take note, where the 7 here
indicating the 7th column of the sheet is the place that I want a
timestamp.
inside the for loop, is to setValue with date in a specified
format of ("yyyy-MM-dd hh:mm:ss"). You are free to edit into any
style you like
ohya, do remember to deploy to activate the trigger with event type
as On Change. That is not limiting to edit, but for all kinds of
changes including paste.
Here's a screenshot on how it would look like:
Lastly, please take note on some of my backgrounds before deciding to or not to have the solution to work for your case. Cheers, and happy coding~!
You cannot get a permanent timestamp with a spreadsheet formula, even with a named function or an Apps Script custom function, because formula results refreshed from time to time. When the formula gets recalculated, the original timestamp is lost.
The easiest way to insert the current date in a cell is to press Control + ; or ⌘;. See the keyboard shortcuts help page.
You can also use an onEdit(e) script to create permanent timestamps. Search this forum for [google-apps-script] timestamp to find many examples.

Shared Spreadsheet with two instances of onEdit, deleting wrong row

I'm trying to create a lead management format using Google Sheets & Apps Script.
The apps script is checking whether the value in column M of sheet Propsect or Interested has changed and depending on the value, moving the row to the respective sheet (Interested, Postponed, Lost, or Booked)
The spreadsheet is shared with my team who'll make changes and with multiple users editing at a time.
Now, the problem is that, as soon as two onEdits are triggered, and if both require rows to be moved, the first instance runs properly but the second one removes the wrong row.
Eg: In sheet Prospect, Row 2 & Row 3 have status changed to Lost & Postponed at the same time. Now, Lost gets triggered properly, however, the Postponed instance deletes the 4th row (now the 3rd row, as row 2 was removed before).
I have tried to add in lockservice to the code so that only one instance is running but that doesn't seem to solve the problem as the event object is still considering the un-updated row number.
Even tried adding flush() at the start & end of the code but didn't work either.
You can access the spreadsheet here.
My code is as follows:
function Master(e) {
var lock = LockService.getScriptLock();
var SS = e.source;
var Sheet = e.source.getActiveSheet();
var Range = e.range;
if(Sheet.getName() == "Prospect" && Range.getColumn() == "13" || Sheet.getName() == "Interested" && Range.getColumn() == "13"){
moveRows(SS,Sheet,Range);
}
lock.releaseLock();
}
function moveRows(SS,Sheet,Range) {
var val1 = Sheet.getRange(Range.getRow(),1,1,10).getDisplayValues();
val1 = String(val1).split(",");
var tar_sheet = SpreadsheetApp.getActive().getSheetByName(Range.getValue());
var row = tar_sheet.getRange(tar_sheet.getLastRow()+1,1,1,val1.length).setValues([val1]);
Sheet.deleteRow(Range.getRow());
}
}
Is there any way for the second onEdit to run only after the first has completed execution? I guess, if that could happen, the problem would be solved?
I Hope I have been able to convey my question properly.
Issue:
Event object e passed to a onEdit(e) is not altered, when two or more edits are done at the same time and the first edit alters the next edit's row number- making e.range.rowStart of the second+ edit unreliable at the time of it's execution.
Possible Solutions:
Do not delete the rows immediately. Mark them for deletion(save the range string in properties service) and delete them later(time trigger), when document is not in use.
Alternatively, Add code guards: Check range.getValue()===e.value. If they're equal, continue to moveRows else keep offseting the range by -1 row until they're both equal.
References:
PropertiesService
Range#offset
I guess you should trigger only one function based on user interaction and then inside that perform conditional operations.
Something like this:
function onEdit(event_object) {
var sheet = event_object.range.getSheet();
var row = event_object.range.getRow();
var column = event_object.range.getColumn();
if (sheet.getName() == "Sheet1") {
// perform operations when Sheet1 is edited
} else if (sheet.getName() == "Sheet2") {
// perform operations when Sheet2 is edited
}
}
Reference :
https://developers.google.com/apps-script/reference/spreadsheet/range
https://developers.google.com/apps-script/guides/triggers/events#edit

On Change Trigger Not Running Script On Change

I have a script to update named ranges when new rows of data are added to the spreadsheet in question:
function updateNamedRanges() {
// get to the right place
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName('ga weekly data pull');
//now update the named ranges if they have changed in length
var openEnded = ["gaCampaign", "gaMedium", "gaSource", "gaSubscriptions", "gaUsers", "gaWeek"];
for(i in openEnded) {
var r = ss.getRangeByName(openEnded[i]);
var rlr = r.getLastRow();
var s = r.getSheet();
var slr = s.getMaxRows();
if(rlr==slr ) continue; // ok as is-skip to next name
var rfr = r.getRow();
var rfc = r.getColumn();
var rnc = r.getNumColumns();
var rnr = slr - rfr + 1;
ss.removeNamedRange(openEnded[i]);
ss.setNamedRange( openEnded[i], s.getRange(rfr, rfc, rnr, rnc ));
}
sheet.getRange("D2").setValue(0); // this gets all the formulas in the sheet to update - just changing any cell
}
Then, within Aps Script editor I go Resources > Current Projects Triggers > Run updateNamedRanges > From Spreadsheet > On change.
Now, if I manually add in a row of data the script runs - great!
But I'm pulling in data with the Google Analytics add on. This add on expands the tab in question when the length of data is longer than the sheet. But when this happens the script does not update.
Is there anything I can do here?
As a backup I'm thinking if I can figure out how to get GAS to add a row from the bottom of the sheet that might do it but that seems like a workaround. Before I go down that path is there a better way?
as you found out, apps script triggers only work when apps script does the changes. yea its lame. if an api outside of apps script modifies the sheet, they wont trigger.
your only option is to use a time trigger to detect a change and process the entire sheet again (since you dont know what changed). One way to achieve this more efficiently is to remember (in a script property) the last modified date from triggers. then a 1minute time trigger checks if modified date is now bigger than the last one saved. if so process the entire sheet.
Run it on a time trigger that runs every minute until Google addresses the issues of not catching the on change event and/or not being able to define open-ended named ranges.
Edited for running the script on open
To keep the sheet from recalculating everytime it is opened whether needed or not.
above the loop place:
var recalc = false;
within the loop below if(rlr==slr ) continue;
recalc = true;
recalculate the sheet only if necessary:
if(recalc) {sheet.getRange("D2").setValue(0)};

Trigger an email when a cell is written into from another app (IFTTT)

So here's what I've been working on. I'm a basketball coach and have a spreadsheet that pulls in all of my players' tweets from IFTTT.com (it basically takes the RSS feed of a twitter list and when it is updated, it updates the spreadsheet).
I have been working on coding that basically says "if a player tweets an inappropriate word, email me immediately."
I've got the code figured out that if I just type in an inappropriate word, it'll turn the cell red and email me. However, I have not figured out how to get the code to email me after IFTTT automatically updates the spreadsheet with tweets.
Here is my code thus far. Right now I've just got one "trigger" word that is "players" just to try and get the spreadsheet to work. Here's the code:
function onEdit(e) {
var ss = SpreadsheetApp.getActiveSpreadsheet();//Get the spreadsheet
var sheet = ss.getActiveSheet()//Get the active sheet
var cell = ss.getActiveCell().activate();//Get the active cell.
var badCell = cell.getA1Notation();//Get the cells A1 notation.
var badCellContent = cell.getValue();//Get the value of that cell.
if (badCellContent.match("players")){
cell.setBackgroundColor("red")
MailApp.sendEmail("antadrag#gmail.com", "Notice of possible inappropriate tweet", "This tweet says: " + badCellContent + ".");
}
}
Here is a link to the spreadsheet I'm working with right now: https://docs.google.com/spreadsheets/d/1g5XaIycy69a3T2YcWhcbBy0hYrxSfoEEz8c4-zP63O8/edit#gid=0
Any help or guidance on this is greatly appreciated! Thanks!
I originally wrote this answer for your previous question, so it includes answers to some of your comments from there, but since you're continuing to go asking the community to write this step-by-step , here's the next step.
The issue I'm running into is that if three tweets come into the spreadsheet at the same time, with my code, it's only going to update the most recent cell, not all three. Does that make sense?
Yes, it does make sense.
When an onEdit() trigger function calls Spreadsheet Service functions to get current info from the sheet, it enters a "Race condition". If any changes occur in the sheet after the change that triggered onEdit(), and the time when it gets scheduled, those changes will be visible when it runs. That's what you see when you assume that the change you're processing is in the last row - by the time you're processing it, there may be a new last row.
Good news, though - the attributes of the event object passed to onEdit contain the details of the change. (The parameter e.) See Event objects.
By using e.range and e.value you'll find you have the location and content of the edited cell that kicked the trigger. If additional tweets arrive before the trigger is serviced, your function won't be tricked into processing the last row.
In new sheets, the onEdit() can get triggered for multiple-cell changes, such as cut & paste. However unlikely that it may happen, it's worth covering.
Well, after getting the spreadsheet all setup & actually using the trigger from IFTTT, it doesn't work. :( I'm assuming it's not dubbing it as the active cell whenever it automatically pulls it into the spreadsheet. Any idea on a workaround on that?
Q: When is an edit not an edit? A: When it's made by a script. In that case, it's a change. You can add an installable on Change function to catch those events. Unfortunately, the change event is less verbose than an edit event, so you are forced to read the spreadsheet to figure out what has changed. My habit is to have the change handler simulate an edit by constructing a fake event (just as we'd do for testing), and passing it to the onEdit function.
So give this a try. This script:
handles a list of "bad words". (Could just as easily be monitoring for mentions of your product or cause.)
has an onEdit() function that uses the event object to evaluate the row(s) that triggered the function call.
colors "bad" tweets
has a function for testing the onEdit() trigger, based on How can I test a trigger function in GAS?
includes playCatchUp(e), an installable trigger function (change and/or time-based) that will evaluate any rows that have not been evaluated before. Script property "Last Processed Row" is used to track that row value. (If you plan to remove rows, you'll need to adjust the property.)
Has the sendMail function commented out.
Enjoy!
// Array of bad words. Could be replaced with values from a range in spreadsheet.
var badWords = [
"array",
"of",
"unacceptable",
"words",
"separated",
"by",
"commas"
];
function onEdit(e) {
if (!e) throw new Error( "Event object required. Test using test_onEdit()" );
Logger.log( e.range.getA1Notation() );
// e.value is only available if a single cell was edited
if (e.hasOwnProperty("value")) {
var tweets = [[e.value]];
}
else {
tweets = e.range.getValues();
}
var colors = e.range.getBackgrounds();
for (var i=0; i<tweets.length; i++) {
var tweet = tweets[i][0];
for (var j=0; j< badWords.length; j++) {
var badWord = badWords[j];
if (tweet.match(badWord)) {
Logger.log("Notice of possible inappropriate tweet: " + tweet);
colors[i][0] = "red";
//MailApp.sendEmail(myEmail, "Notice of possible inappropriate tweet", tweet);
break;
}
}
}
e.range.setBackgrounds(colors);
PropertiesService.getDocumentProperties()
.setProperty("Last Processed Row",
(e.range.getRowIndex()+tweets.length-1).toString());
}
// Test function, adapted from https://stackoverflow.com/a/16089067/1677912
function test_onEdit() {
var fakeEvent = {};
fakeEvent.authMode = ScriptApp.AuthMode.LIMITED;
fakeEvent.user = "amin#example.com";
fakeEvent.source = SpreadsheetApp.getActiveSpreadsheet();
fakeEvent.range = fakeEvent.source.getActiveSheet().getDataRange();
// e.value is only available if a single cell was edited
if (fakeEvent.range.getNumRows() === 1 && fakeEvent.range.getNumColumns() === 1) {
fakeEvent.value = fakeEvent.range.getValue();
}
onEdit(fakeEvent);
}
// Installable trigger to handle change or timed events
// Something may or may not have changed, but we won't know exactly what
function playCatchUp(e) {
// Check why we've been called
if (!e)
Logger.log("playCatchUp called without Event");
else {
// If onChange and the change is an edit - no work to do here
if (e.hasOwnProperty("changeType") && e.changeType === "EDIT") return;
// If timed trigger, nothing special to do.
if (e.hasOwnProperty("year")) {
var date = new Date(e.year, e.month, e["day-of-month"], e.hour, e.minute, e.second);
Logger.log("Timed trigger: " + date.toString() );
}
}
// Find out where to start processing tweets
// The first time this runs, the property will be null, yielding NaN
var lastProcRow = parseInt(PropertiesService.getDocumentProperties()
.getProperty("Last Processed Row"));
if (isNaN(lastProcRow)) lastProcRow = 0;
// Build a fake event to pass to onEdit()
var fakeEvent = {};
fakeEvent.source = SpreadsheetApp.getActiveSpreadsheet();
fakeEvent.range = fakeEvent.source.getActiveSheet().getDataRange();
var numRows = fakeEvent.range.getLastRow() - lastProcRow;
if (numRows > 0) {
fakeEvent.range = fakeEvent.range.offset(lastProcRow, 0, numRows);
onEdit(fakeEvent);
}
else {
Logger.log("All caught up.");
}
}

Writing an 'Undo' Function for Google Spreadsheets Using GAS

Currently there is no undo() function for Google Apps Script in the Spreadsheet/Sheet/Range classes. There were a few issues opened on the Issue Tracker, I can only find one now (I don't know what Triaged means): here.
There have been suggested workarounds using the DriveApp and revision history but I took a look around and didn't find anything (maybe it's buried?). In any case, an undo() function is incredibly necessary for so many different operations. I could only think of one kind of workaround, but I haven't been able to get it to work (the way the data is stored, I don't know if it's even possible). Here is some pseudo -
function onOpen () {
// Get all values in the sheet(s)
// Stringify this/each (matrix) using JSON.stringify
// Store this/each stringified value as a Script or User property (character limits, ignore for now)
}
function onEdit () {
// Get value of edited cell
// Compare to some value (restriction, desired value, etc.)
// If value is not what you want/expected, then:
// -----> get the stringified value and parse it back into an object (matrix)
// -----> get the old data of the current cell location (column, row)
// -----> replace current cell value with the old data
// -----> notifications, coloring cell, etc, whatever else you want
// If the value IS what you expected, then:
// -----> update the 'undoData' by getting all values and re-stringifying them
// and storing them as a new Script/User property
}
Basically, when the Spreadsheet is opened store all values as a Script/User property, and only reference them when certain cell criteria(on) are met. When you want to undo, get the old data that was stored at the current cell location, and replace the current cell's value with the old data. If the value doesn't need to be undone, then update the stored data to reflect changes made to the Spreadsheet.
So far my code has been a bust, and I think it's because the nested array structure is lost when the object is stringified and stored (e.g., it doesn't parse correctly). If anyone has written this kind of function, please share. Otherwise, suggestions for how to write this will be helpful.
Edit: These documents are incredibly static. The number of rows/columns will not change, nor will the location of the data. Implementing a get-all-data/store-all-data-type function for temporary revision history will actually suit my needs, if it is possible.
I had a similar problem when I needed to protect the sheet yet allow edits via a sidebar. My solution was to have two sheets (one hidden). If you edit the first sheet, this triggers the onEdit procedure and reloads the values from the second sheet. If you unhide and edit the second sheet, it reloads from the first. Works perfectly, and quite entertaining to delete data on mass and watch it self repair!
As long as you will not add or remove rows and columns, you can rely on the row and column numbers as indices for historic values that you store in ScriptDb.
function onEdit(e) {
// Exit if outside validation range
// Column 3 (C) for this example
var row = e.range.getRow();
var col = e.range.getColumn();
if (col !== 3) return;
if (row <= 1) return; // skip headers
var db = ScriptDb.getMyDb();
// Query database for history on this cell
var dbResult = db.query({type:"undoHistory",
row:row,
col:col});
if (dbResult.getSize() > 0) {
// Found historic value
var historicObject = dbResult.next();
}
else {
// First change for this cell; seed historic value
historicObject = db.save({type:"undoHistory",
row:row,
col:col,
value:''});
}
// Validate the change.
if (valueValid(e.value,row,col)) {
// update script db with this value
historicObject.value = e.value;
db.save(historicObject);
}
else {
// undo the change.
e.range.getSheet()
.getRange(row,col)
.setValue(historicObject.value);
}
}
You need to provide a function that validates your data values. Again, in this example we only care about data in one column, so the validation is very simple. If you needed to perform different types of validation different columns, for instance, then you could switch on the col parameter.
/**
* Test validity of edited value. Return true if it
* checks out, false if it doesn't.
*/
function valueValid( value, row, col ) {
var valid = false;
// Simple validation rule: must be a number between 1 and 5.
if (value >= 1 && value <= 5)
valid = true;
return valid;
}
Collaboration
This undo function will work for spreadsheets that are edited collaboratively, although there is a race condition around storing of historic values in the script database. If multiple users made a first edit to a cell at the same time, the database could end up with multiple objects representing that cell. On subsequent changes, the use of query() and the choice to pick only the first result ensures that only one of those multiples would be selected.
If this became a problem, it could be resolved by enclosing the function within a Lock.
Revised the answer from the group to allow for range when user selects multiple cells:
I have used what I would call "Dual Sheets".
One sheet acts as a backup / master and the other as the active sheet
/**
* Test function for onEdit. Passes an event object to simulate an edit to
* a cell in a spreadsheet.
* Check for updates: https://stackoverflow.com/a/16089067/1677912
*/
function test_onEdit() {
onEdit({
user : Session.getActiveUser().getEmail(),
source : SpreadsheetApp.getActiveSpreadsheet(),
range : SpreadsheetApp.getActiveSpreadsheet().getActiveCell(),
value : SpreadsheetApp.getActiveSpreadsheet().getActiveCell().getValue(),
authMode : "LIMITED"
});
}
function onEdit() {
// This script prevents cells from being updated. When a user edits a cell on the master sheet,
// it is checked against the same cell on a helper sheet. If the value on the helper sheet is
// empty, the new value is stored on both sheets.
// If the value on the helper sheet is not empty, it is copied to the cell on the master sheet,
// effectively undoing the change.
// The exception is that the first few rows and the first few columns can be left free to edit by
// changing the firstDataRow and firstDataColumn variables below to greater than 1.
// To create the helper sheet, go to the master sheet and click the arrow in the sheet's tab at
// the tab bar at the bottom of the browser window and choose Duplicate, then rename the new sheet
// to Helper.
// To change a value that was entered previously, empty the corresponding cell on the helper sheet,
// then edit the cell on the master sheet.
// You can hide the helper sheet by clicking the arrow in the sheet's tab at the tab bar at the
// bottom of the browser window and choosing Hide Sheet from the pop-up menu, and when necessary,
// unhide it by choosing View > Hidden sheets > Helper.
// See https://productforums.google.com/d/topic/docs/gnrD6_XtZT0/discussion
// modify these variables per your requirements
var masterSheetName = "Master" // sheet where the cells are protected from updates
var helperSheetName = "Helper" // sheet where the values are copied for later checking
var ss = SpreadsheetApp.getActiveSpreadsheet();
var masterSheet = ss.getActiveSheet();
if (masterSheet.getName() != masterSheetName) return;
var masterRange = masterSheet.getActiveRange();
var helperSheet = ss.getSheetByName(helperSheetName);
var helperRange = helperSheet.getRange(masterRange.getA1Notation());
var newValue = masterRange.getValues();
var oldValue = helperRange.getValues();
Logger.log("newValue " + newValue);
Logger.log("oldValue " + oldValue);
Logger.log(typeof(oldValue));
if (oldValue == "" || isEmptyArrays(oldValue)) {
helperRange.setValues(newValue);
} else {
Logger.log(oldValue);
masterRange.setValues(oldValue);
}
}
// In case the user pasted multiple cells this will be checked
function isEmptyArrays(oldValues) {
if(oldValues.constructor === Array && oldValues.length > 0) {
for(var i=0;i<oldValues.length;i++) {
if(oldValues[i].length > 0 && (oldValues[i][0] != "")) {
return false;
}
}
}
return true;
}