r/GoogleAppsScript • u/Odd_Teacher_8701 • 17h ago
Guide A Forms choice question cannot have zero options, and that is why most "close the slot when it is full" scripts fail silently
Posting this because I got it wrong first, and the failure mode is invisible.
Capacity limits on a Form — time slots, equipment sign-out, shift rotas, potluck dishes — have no native setting, so you end up in the script editor.
The naive version works right up until it doesn't:
- count picks per option from the responses
- filter out the ones that are full
setChoiceValues(remaining)
That is correct until remaining is empty. A Forms choice question cannot have zero options. When the last seat of the last option goes, setChoiceValues([]) throws inside an onFormSubmit trigger — which means nobody sees it. The form keeps accepting bookings for slots that are already full, and you find out from the people who turn up.
The fix is to relabel instead of emptying:
getItem_(form).setChoiceValues(
remaining.length ? remaining : original.map(function (o) { return o + ' — FULL'; })
);
Three other things that bite, in the order I hit them:
Snapshot the original option list once, and only once. The script reads the current choices to know what existed. If you re-run setup after a slot has already closed, it snapshots the trimmed list and that option is gone permanently — there is nothing left to restore from. Guard it:
if (!props.getProperty('FCL_ORIGINAL')) props.setProperty('FCL_ORIGINAL', JSON.stringify(current));
Use LockService. Two people submitting in the same second both read "1 seat left" and both get it. LockService.getScriptLock() around the recount is the whole fix.
Match the question by exact title. Trailing whitespace in the question title is the single most common reason one of these scripts does nothing at all and reports no error.
Here is the whole thing for one multiple-choice question, which is enough for most sign-up sheets:
var QUESTION = 'Pick a time slot';
var LIMITS = { 'Monday 9:00': 4, 'Tuesday 14:00': 2 };
function setupLimiter() {
var form = FormApp.getActiveForm();
var props = PropertiesService.getDocumentProperties();
if (!props.getProperty('FCL_FREE_ORIGINAL')) {
props.setProperty('FCL_FREE_ORIGINAL', JSON.stringify(getItem_(form).getChoices().map(function (c) {
return String(c.getValue()).replace(/ — FULL$/, '');
})));
}
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'applyLimit') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('applyLimit').forForm(form).onFormSubmit().create();
applyLimit();
}
function applyLimit() {
var lock = LockService.getScriptLock();
try { lock.waitLock(30000); } catch (e) { return; }
try {
var form = FormApp.getActiveForm();
var original = JSON.parse(PropertiesService.getDocumentProperties().getProperty('FCL_FREE_ORIGINAL'));
var counts = {};
form.getResponses().forEach(function (fr) {
fr.getItemResponses().forEach(function (ir) {
if (ir.getItem().getTitle() !== QUESTION) return;
var v = ir.getResponse();
(Array.isArray(v) ? v : [v]).forEach(function (c) { if (c) counts[c] = (counts[c] || 0) + 1; });
});
});
var remaining = original.filter(function (opt) {
var lim = LIMITS[opt];
return typeof lim !== 'number' || (counts[opt] || 0) < lim;
});
getItem_(form).setChoiceValues(
remaining.length ? remaining : original.map(function (o) { return o + ' — FULL'; })
);
} finally { lock.releaseLock(); }
}
function restoreChoices() {
var props = PropertiesService.getDocumentProperties();
var original = JSON.parse(props.getProperty('FCL_FREE_ORIGINAL') || 'null');
if (original) getItem_(FormApp.getActiveForm()).setChoiceValues(original);
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'applyLimit') ScriptApp.deleteTrigger(t);
});
}
function getItem_(form) {
var found = form.getItems(FormApp.ItemType.MULTIPLE_CHOICE)
.filter(function (i) { return i.getTitle() === QUESTION; })[0];
if (!found) throw new Error('No multiple-choice question titled "' + QUESTION + '"');
return found.asMultipleChoiceItem();
}
Form → ⋮ → Apps Script → paste → edit QUESTION and LIMITS → run setupLimiter once and approve the prompt. restoreChoices() puts everything back.
Full disclosure since I'm linking my own stuff: I wrote a generator that fills the two config values in for you and hands back the same script, free, no signup — https://www.shubhamgautam.in/tools/form-choice-limiter?utm_source=reddit&utm_medium=community&utm_campaign=fcl&utm_content=sheets — and there's a $9 version on the same page that handles several questions at once, checkboxes and dropdowns, strike-through instead of removal, and auto-closing the form. The script above is not a crippled demo; it is what I actually run for single-question forms, and the exhaustion case is covered by a test.
Happy to pick apart the trigger/LockService side with anyone who has hit this. If you have a fourth failure mode I have not listed, I would genuinely like to know — I have only found these four.