Google Sheets add-on: Set anonymous/dynamic functions to Menu using Google App Script

I would like to set dynamic functions for dynamic menus in Google sheets add-on. I am using the following code:

function onOpen(e) {
  var menu = SpreadsheetApp.getUi().createAddonMenu();
  
  for (var i = 0; i < array.length; i++) {
        const element = array[i];
        var functionName = "_" + element.name;
        var args = element.args;
        
        this[functionName] = dynamicItem(args); //didn't work
        //this[functionName] = function () {myopen(args);} //didn't work
        //eval("function " + functionName + "() { myopen('" + args + "') }"); //didn't work
        menu.addItem(element.name, functionName);
      }
   menu.addToUi();
 }

 function dynamicItem(args) {
    return function () {
       myopen(args);
    };
 }

When I click on the menu item, I get the following exception:

"Script function not found: function-name"

I got help from Anonymous function, Dynamic menus and Dynamically Updating Custom Menu, but I don't know why it's not working for me.

Any help would be highly appreciated.

Thanks.


Summary:

Google apps script runs in a stateless environment. Anything stored in global object is not maintained across sessions. If you add something to the global object during a session, it is not available in the next session run. Use Immediately Invoked Functions or Call functions in global scope to fill the global scope(this object) before any function is actually called by the user interface.

Explanation:

The workaround mentioned by [email protected] in comment#17 of this issue and by Tanaike in this answer both utilises filling up the global scope using Closures/Immediately Invoked Functions(IIFE).

To understand this, You need to understand when Script function names are read and loaded. The following steps happen in order:

  1. Menu Click

  2. All Script in script editor is executed and list of functions names in global this is created(In this step, no function is run/called, but all of the script is fully executed). This is equivalent to loading a webpage with your script: <script>...code.gs...</script>

  3. Check if the currently invoked button's/menu's function name is present in global this,

  4. If present, execute the function(i.e.,calling the function, that the linked button/menu's function name refers to). This is like adding myFunction() at the end of the already loaded script. If not found, throw Error: Script function not found

  5. Script ends. This is like closing the loaded webpage. All "state" is lost. No global scope or this is stored permanently.

When dynamically adding menu items using this[function-name], it is important to realize when you're adding the function. If you're adding it during onOpen, then this has those functions in global scope during onOpen execution, but it immediately lost after onOpen script execution is complete.

function onOpen(){
  this['a'] = () => 'a';
  SpreadsheetApp.getUi()
    .createMenu("Test")
    .addItem("Call function a","a")
    .addToUi()
}

This will successfully add a function to Ui menu, but notice that a is only added to global this scope during onOpen execution. This this is then lost after that execution is complete and a new this( global scope) is created, next time any function is called(Step 1 to 5 repeats). So, when a button click occurs, step 2 creates a new this and looks for a function named a in all the script, but wouldn't find any, because this newly created this doesn't have a(because onOpen is declared, but not executed and therefore a is not added to this this time).

Solution:

During or before Step2, You would need to add the function to the global this:

function onOpen(){
  SpreadsheetApp.getUi()
    .createMenu("Test")
    .addItem("Call function a","a")
    .addToUi()
}
(function IIFE(){
  this['a'] = () => 'a';
})(); 

The IIFE function above intercepts Step 2 "every time", any function is called. So, the a is always present in this at or after Step 3. In Tanaike's solution, this is done by installFunctions() in a global scope. That function is executed every time any function is called. The same is true in case of createMenuFunctions(this); in comment#17.

Documentation excerpts:

From add-on documentation link,

Warning: When your onOpen(e) function runs, the entire script is loaded and any global statements are executed. These statements execute under the same authorization mode as onOpen(e) and fail if the mode prohibits them. This prevents onOpen(e) from running. If your published add-on fails to add its menu items, look in the browser's JavaScript console to see if an error was thrown, then examine your script to see whether the onOpen(e) function or global variables call services that aren't allowed in AuthMode.NONE.

Sample script:

/**Runs every time any script function is called*/
(function IIFE(scope) {
  'use strict';
  scope['options'] = ['a', 'b', 'c']; //pollute current scope
  options.forEach(
    option =>
      (scope[option] = () =>
        SpreadsheetApp.getUi().alert(`You clicked option ${option}`))
  );
})(this);//pass global scope

function onOpen() {
  const testMenu = SpreadsheetApp.getUi().createMenu('Test');
  options.forEach(option =>
    testMenu.addItem('Call function ' + option, option)
  );
  testMenu.addToUi();
}

References:

  • IIFE

  • Related answer

  • Stateless vs Stateful


Modification points:

  • In your script, when the Spreadsheet is opened, onOpen(e) is run only one time. By this, when the menu is selected, the functions are not installed. I think that this is the reason of your issue.
  • In order to run the dynamically installed functions, it is required to run the script for creating the menu every time. It seems that this is due to the current specification.

When above points are reflected to your script, it becomes as follows.

Modified script:

function installFunctions() {
  
  // Samples
  var array = [{name: "sample1", args: "sample1"}, {name: "sample2", args: "sample2"}, {name: "sample3", args: "sample3"}];
  
  var menu = SpreadsheetApp.getUi().createMenu("sample");
  for (var i = 0; i < array.length; i++) {
    const element = array[i];
    var functionName = "_" + element.name;
    var args = element.args;
    this[functionName] = dynamicItem(args);
    menu.addItem(element.name, functionName);
  }
  menu.addToUi();
}

installFunctions(); // This function is run when the Spreadsheet is opened and each menu is selected.

function onOpen() {}

function dynamicItem(args) {
  return function () {
    Browser.msgBox(args);  // Sample script.
    // myopen(args);
  };
}
  • In this modified script, when the Spreadsheet is opened, the custom menu is created. And, when the menu is selected, the dynamically installed function is run.
  • This script runs the line of installFunctions(); every run of the functions. By this, the installed functions can be run. This is the important for creting the dynamically installed functions.

References:

  • Custom Menus in Google Workspace
  • Related answer1
  • Related answer2

A masterful explanation from @TheMaster! I created a template with just the bare necessities and avoiding too much code in the IIFE

   function onOpen() {
      var ui = SpreadsheetApp.getUi() ;
      var menu = ui.createMenu("Choose") ;
      Options.forEach( O => menu.addItem(O[0], O[1]) ) ;
      menu.addToUi() ;
    }
    
    var Options = [] ;
    
    (function() {
      let items = ['X','Y','Z'] ;
      /*  or for example: JSON.parse(PropertiesService.getScriptProperties().getProperty("items")) ; /**/
      items.forEach( function(item) {
        let funcName = 'Choose$'+item ;
        Options.push([item,funcName]) ;
        this[funcName] = () => Handle(item) ;
      }) ;
    })() ;
    
    const Handle = it => console.log("we got %s", it) ;

Result: Result