One-click approve / reject for a row your ops team will actually use

TL;DR — Add a custom menu that approves or rejects the active row: status column Pending/Approved/Rejected, optional note, toast, and a short email to the requester — happy path clickable, edge cases documented in comments. Plus a soft NitroGAS note for teammate-readable status messages.

Approvals in a spreadsheet usually look like this: someone types “ok” in column H, someone else types “approved!!” in column J, and nobody’s sure which row is actually done. The fix isn’t a new app. It’s one menu item that stamps a real status, optional note, and tells the requester — without anyone touching formulas or remembering the column letters.

This is a different shape from treating a sheet like a job queue. A queue is for scripts claiming work. This is for humans clicking approve on the row in front of them.

TL;DR

  • Status column with Pending / Approved / Rejected (data validation list)
  • Custom menu: Approve row / Reject row acting on the active row
  • Optional prompt for a note; stamp who + when
  • Toast for the clicker; optional email to the requester via a small HTML template
  • Happy path is click-simple; weird cases are handled and commented

Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.

Sheet shape

A Id | B Requester | C Email | D Item | E Status | F Note | G DecidedBy | H DecidedAt

Put a dropdown on Status (Pending, Approved, Rejected). New rows start Pending.

The menu

function onOpen() { SpreadsheetApp.getUi() .createMenu('Approvals') .addItem('Approve row', 'approveActiveRow') .addItem('Reject row', 'rejectActiveRow') .addToUi(); } function approveActiveRow() { decideActiveRow_('Approved'); } function rejectActiveRow() { decideActiveRow_('Rejected'); }

Decide the active row

var APPROVAL_SHEET = 'Requests'; function decideActiveRow_(newStatus) { var ss = SpreadsheetApp.getActive(); var sheet = ss.getActiveSheet(); var ui = SpreadsheetApp.getUi(); // Edge: menu used on the wrong tab if (sheet.getName() !== APPROVAL_SHEET) { ui.alert('Open the "' + APPROVAL_SHEET + '" tab and select a request row.'); return; } var rowIndex = sheet.getActiveRange().getRow(); // Edge: header row or blank row if (rowIndex < 2) { ui.alert('Select a request row (not the header).'); return; } var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; var col = function (name) { var i = headers.indexOf(name); if (i === -1) throw new Error('Missing column: ' + name); return i + 1; }; var statusCell = sheet.getRange(rowIndex, col('Status')); var current = String(statusCell.getValue() || '').trim(); // Edge: already decided — don’t silently flip Approved ↔ Rejected if (current && current !== 'Pending') { var again = ui.alert( 'This row is already ' + current + '. Change it to ' + newStatus + '?', ui.ButtonSet.YES_NO ); if (again !== ui.Button.YES) return; } // Optional note (Reject usually wants one) var noteResp = ui.prompt( newStatus + ' — optional note for the requester', ui.ButtonSet.OK_CANCEL ); if (noteResp.getSelectedButton() !== ui.Button.OK) return; var note = noteResp.getResponseText().trim(); // Lock so two approvers don’t race the same row var lock = LockService.getDocumentLock(); if (!lock.tryLock(5000)) { ui.alert('Someone else is updating approvals. Try again in a moment.'); return; } try { var now = Utilities.formatDate(new Date(), ss.getSpreadsheetTimeZone(), 'yyyy-MM-dd HH:mm'); var who = Session.getActiveUser().getEmail() || 'unknown'; statusCell.setValue(newStatus); sheet.getRange(rowIndex, col('Note')).setValue(note); sheet.getRange(rowIndex, col('DecidedBy')).setValue(who); sheet.getRange(rowIndex, col('DecidedAt')).setValue(now); SpreadsheetApp.flush(); } finally { lock.releaseLock(); } var row = { id: sheet.getRange(rowIndex, col('Id')).getValue(), requester: sheet.getRange(rowIndex, col('Requester')).getValue(), email: sheet.getRange(rowIndex, col('Email')).getValue(), item: sheet.getRange(rowIndex, col('Item')).getValue(), status: newStatus, note: note }; ss.toast(row.item + ' → ' + newStatus, 'Approvals', 6); notifyRequester_(row); }

Tell the requester (HTML-safe)

Build the notice with fillHtmlTemplate so a note with < or & doesn’t break the email.

var NOTICE_HTML = '<p>Hi {{requester}},</p>' + '<p>Your request <strong>{{item}}</strong> (#{{id}}) was <strong>{{status}}</strong>.</p>' + '<p>{{note}}</p>'; function notifyRequester_(row) { // Edge: no email on file — toast already told the approver; don’t throw if (!row.email) return; var html = fillHtmlTemplate(NOTICE_HTML, { requester: row.requester, item: row.item, id: row.id, status: row.status, note: row.note || '—' }); GmailApp.sendEmail(row.email, 'Request ' + row.status + ': ' + row.item, 'Please view in HTML.', { htmlBody: html, name: 'Ops approvals' }); }

Edge cases (in comments, not in the happy path)

  • Wrong tab / header row — alert and exit early
  • Already decided — confirm before flipping
  • Cancel on the note prompt — no write
  • Two approvers at once — document lock + short tryLock
  • Missing email — skip notice, still update the row
  • Protected Status column — run as someone who can edit, or protect everything except Status/Note for approvers

Make it feel native

  • Conditional formatting: Approved = green, Rejected = red, Pending = amber
  • Freeze the header row so “select a row” is obvious
  • Filter view: “Pending only” for the daily approver sweep

Soft Co-Pilot note

NitroGAS puts fillHtmlTemplate in the snippet catalog. Ask Co-Pilot for teammate-readable status messages — the toast text, the alert copy, and the requester email — so ops see plain English, not “Exception: Range not found.” Free core; Co-Pilot is a pass when you want that copy done.

Closing checklist

  • Status dropdown: Pending / Approved / Rejected
  • Approve / Reject menu on the active row
  • Optional note + DecidedBy + DecidedAt stamped
  • Toast for the approver; optional HTML email to the requester
  • Edge cases handled with early returns and comments

Happy Coding!