Страницы

среда, 6 февраля 2013 г.

Google Script. Feedback.

Способ создания формы обратной связи с использованием инструмента "формы", рассмотренный в предыдущей статье, не лишен недостатков. В этот раз я расскажу, как написать более "продвинутый" feedback. С валидацией, регистрацией пользователей, блек-джеком и прочими свистелками :). Не самый легкий для изложения материал, поэтому сразу хочу попросить уважаемого читателя запастись терпением. В свою очередь постараюсь, чтобы к финалу композиции, состоящей из трех частей: фронтэнда, бэкэнда и скрипта регистрации, - понимание вопроса, а также его решения, возникло.

Начнем с бэкэнда - таблицы. Заходим на Google Диск, нажимаем большую красную кнопку: Создать - Таблица. Я назвал ее TestFeedbackNoForm. Единственный лист таблицы назовем "Сообщения". Нетрудно догадаться, что этот самый лист будет содержать информацию о сообщениях пользователей нашей системы. Добавим немного интерактива, для чего реализуем возможность отвечать на сообщения, плюс возможность пользователей получить ответ.
Как-то запутанно получилось. Короче. Пишем заголовки столбцов, добавляем тестовые данные.

По-моему становится понятнее о чем толкую.
С целью более наглядного представления о наших вопросах-ответах нарисуем интерфейс. Открываем редактор скриптов: Инструменты - Редактор скриптов. Назовем проект TestFeedbackNoForm.

Пишем функцию отображения информации о нашем вопросе, для чего переименуем созданную по-умолчанию функцию myFunction() в openRequest().
function openRequest() {
  var app = UiApp.createApplication().setWidth('550').setHeight('260');   

  var ss = SpreadsheetApp.getActiveSpreadsheet(); 
  var sh = ss.getActiveSheet();

  // интерфейс 
  var vp = app.createVerticalPanel();
  app.add(vp);
  // номер вопроса
  var lblRequestNumber = app.createLabel()
    .setId('lblRequestNumber').setStyleAttribute('text-align', 'center').setStyleAttribute('font-weight', 'bold');
  vp.add(lblRequestNumber);
  // статус
  var listStatus = app.createListBox().setName('listStatus').setId('listStatus').addItem('новый').addItem('в процессе').addItem('решен');
  // свойства вопроса 
  var grid1 = app.createGrid(3, 4).setId('grid1')
    .setText(0, 0, 'Дата поступления: ')
    .setText(0, 2, 'Дата изменения: ')
    .setText(1, 0, 'Пользователь: ')
    .setText(1, 2, 'Статус: ').setWidget(1, 3, listStatus)
    .setText(2, 0, 'E-mail: ')
    .setText(2, 2, 'Телефон: ').setStyleAttribute(0, 1, 'width', '150px').setStyleAttribute(0, 3, 'width', '150px');

  // содержание и ответ
  var txtContent = app.createTextArea().setName('txtContent').setId('txtContent').setWidth(270).setHeight(100).setEnabled(false);
  var txtResponse = app.createTextArea().setName('txtResponse').setId('txtResponse').setWidth(270).setHeight(100); 

  var grid2 = app.createGrid(2, 2).setId('grid2')
    .setText(0, 0, 'Содержание: ').setWidget(1, 0, txtContent)
    .setText(0, 1, 'Ответ: ').setWidget(1, 1, txtResponse).setWidth(540);

  // кнопки
  var btnOK = app.createButton('Сохранить').setWidth(100).setStyleAttribute('margin', '0px');
  var btnCancel = app.createButton('Закрыть').setWidth(100).setStyleAttribute('margin', '0px');
  var btnUp = app.createButton('Вверх ^').setWidth(100).setStyleAttribute('margin', '0px');
  var btnDown = app.createButton('Вниз v').setWidth(100).setStyleAttribute('margin', '0px');
    
  var grid3 = app.createGrid(1, 4)
    .setWidget(0, 0, btnOK).setWidget(0, 1, btnUp).setWidget(0, 2, btnDown).setWidget(0, 3, btnCancel)
    .setWidth(550).setStyleAttribute(0, 1, 'text-align', 'right').setStyleAttribute(0, 3, 'text-align', 'right');

  // добавляем на вертикальную панель
  vp.add(grid1).add(grid2).add(grid3); 

  ss.show(app);
}
Запускаем наш код.

Добавим проверку на то, что открыт именно тот лист, который нужен (пока в таблице он один, но позже будут еще), и убедимся что выбран именно диапазон с данными:
// проверки
  if (sh.getName() != 'Сообщения') // проверяем лист
    return;
  var range = sh.getActiveRange();
  var rangeRowIndex = range.getRowIndex();
  if (rangeRowIndex > sh.getDataRange().getLastRow() || rangeRowIndex == 1) // проверяем диапазон
    return;
Добавим этот код перед началом создания интерфейса (выше строки "// интерфейс"). Выберем ячейку вне диапазона с данными - в какой-нибудь пятой или шестой строке и затестим код... интерфейс не отобразился, что и требовалось.

Пришло время добавить в таблицу еще один лист, в котором будет храниться информация о зарегистрированных пользователях нашей системы.
Нажимаем на плюсик в левом нижнем углу таблицы, называем лист "Пользователи", пишем заголовки столбцов и добавляем одного тестового пользователя.

В поле E-mail пишем адрес ящика текущего аккаунта Google. Еще раз затестим наш код после активации листа "Пользователи"... то, что нужно.

Пишем функцию, которая будет возвращать нам объект - пользователя, со свойствами: имя, e-mail и телефон.
// получаем объект - пользователей со свойствами name, mail и phone
function getUsersObject(range) {
  var rowObjects = [];
    for (var i = 1; i < range.length; i++) {
      var row = {name: range[i][1], mail: range[i][2], phone: range[i][3]};     
      rowObjects.push(row);
    }
  return rowObjects;
}
Осталось вытащить необходимые данные и запихнуть их в виджеты.
function setWidgetsText(app, ss, sh, range) {
  // получаем диапазон со свойствами заявки
  var requestRange = sh.getRange(range.getRowIndex(), 1, 1, 7).getValues(); 
  // переводим в объект
  var request = {dateIn: requestRange[0][0], number: requestRange[0][1],
                 user: requestRange[0][2], content: requestRange[0][3], status: requestRange[0][4],
                 dateChanged: requestRange[0][5], response: requestRange[0][6],
                 mail: 'не найден', phone: 'не найден'};

  // получаем диапазон со свойствами пользователей
  var usersRange = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Пользователи')
    .getDataRange().getValues();
  // получаем объект пользователи - с листа "Пользователи"
  var users = getUsersObject(usersRange);
  // ищем пользователя
  for (var i = 0; i < users.length; i++) {
    if (users[i].name == request.user) {
      request.mail = users[i].mail;
      request.phone = users[i].phone;
      break;
    }
  }

  //интерфейс 
  app.getElementById('lblRequestNumber').setText('Вопрос №: ' + request.number);

  switch (request.status) {   
    case 'в процессе':
      app.getElementById('listStatus').setSelectedIndex(1);
      break;
    case 'решен':
      app.getElementById('listStatus').setSelectedIndex(2);
      break;
    case 'новый':
    default:
      app.getElementById('listStatus').setSelectedIndex(0);
      break;
  }

  app.getElementById('grid1').setText(0, 1, Utilities.formatDate(new Date(request.dateIn), "GMT+4", "yyyy-MM-dd' 'HH:mm:ss"))
    .setText(0, 3, Utilities.formatDate(new Date(request.dateChanged), "GMT+4", "yyyy-MM-dd' 'HH:mm:ss"))
    .setText(1, 1, request.user).setWidget(2, 1, app.createAnchor(request.mail, 'mailto:' + request.mail))
    .setText(2, 3, request.phone);
  app.getElementById('txtContent').setText(request.content);
  app.getElementById('txtResponse').setText(request.response);
}
Добавим вызов функции setWidgetsText() в функцию openRequest() перед строкой "ss.show(app);". Открываем лист "Сообщения", выбираем, например, Вопрос 2, возвращаемся в Редактор скриптов и запускаем функцию openRequest().

Пишем функцию создания меню.
// закидываем меню
function onOpen() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var menu = [ {name: "Открыть", functionName: "openRequest"} ];
  ss.addMenu("Вопрос", menu);
}
Запускаем ее, открываем лист "Сообщения" и запускаем наш код уже с помощью меню.

Для реализации функционала кнопок создадим новый файл скрипта: Редактор скриптов - Файл - Создать - Скрипт. Назовем его Кнопки.gs.

Начнем с функции закрытия интерфейса.
function appClose(e) {
  var app = UiApp.getActiveApplication();
  app.close();
  return app;
}
Переходим к функциям навигации.
function onUpClick(e) {
  var app = UiApp.getActiveApplication();

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sh = ss.getActiveSheet();
  // проверки
  if (sh.getName() != 'Сообщения') // проверяем лист
    return; 
  if (sh.getActiveRange().getRowIndex() == 2) {
    ss.toast('Выше некуда :)');
    return;
  }

  var range = sh.getActiveRange().offset(-1, 0).activate();
  
  setWidgetsText(app, ss, sh, range);
  
  return app;
}

function onDownClick(e) {
  var app = UiApp.getActiveApplication();

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sh = ss.getActiveSheet();
  // проверки
  if (sh.getName() != 'Сообщения') // проверяем лист
    return;
  if (sh.getActiveRange().getRowIndex() == sh.getDataRange().getLastRow()) {
    ss.toast('Ниже некуда :)');
    return;
  }

  var range = sh.getActiveRange().offset(1, 0).activate(); 

  setWidgetsText(app, ss, sh, range);

  return app;
}
И, на десерт, пишем функцию сохранения изменений.
function onBtnOKClick(e) {
  var app = UiApp.getActiveApplication();

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sh = ss.getActiveSheet();
  if (sh.getName() != 'Сообщения') // проверяем лист
    return;

  try {     
    var range = sh.getActiveRange();
    var rangeRowIndex = range.getRowIndex();
    sh.getRange(rangeRowIndex, 4, 1, 4).setValues([[e.parameter.txtContent, e.parameter.listStatus,new Date(),e.parameter.txtResponse]]);
  
    var requestRange = sh.getRange(rangeRowIndex, 1, 1, 7);
    switch (e.parameter.listStatus) {
      case 'в процессе':
        requestRange.setBackgroundColor('SkyBlue');
        break;
      case 'решен':
        requestRange.setBackgroundColor('LimeGreen');
        break;
      case 'новый':
      default:
        requestRange.setBackgroundColor('Tomato');     
        break;
    }
  
    ss.toast('Изменения успешно сохранена')
  
  } catch(e) {
    Logger.log(new Date() + " " + e);
    ss.toast('В процессе сохранения изменений произошла ошибка');
  }

  return app;
}
Сохраняем изменения в файле Кнопки.gs, возвращаемся в созданный по-умолчанию файл Код.gs и в функцию openRequest() после кода создания кнопок добавляем код обработчиков событий для них.
// обработчики событий для кнопок
  var handlerCancel = app.createServerHandler('appClose');
  btnCancel.addClickHandler(handlerCancel);

  var handlerOK = app.createServerHandler('onBtnOKClick').addCallbackElement(vp);
  btnOK.addClickHandler(handlerOK);

  var handlerUp = app.createServerHandler('onUpClick').addCallbackElement(vp);
  btnUp.addClickHandler(handlerUp);

  var handlerDown = app.createServerHandler('onDownClick').addCallbackElement(vp);
  btnDown.addClickHandler(handlerDown);
Сохраняем изменения в файле Код.gs. Возвращаемся в таблицу, тестим навигацию.
Такой вот бэкэнд получился.

Переходим к фронтэнду. Создаем файл скрипта: Google Диск - Создать - Еще - Скрипт. Я назвал его TestFeedbackNoForm.
Переименуем myFunction() в doGet() и нарисуем интерфейс.
function doGet() {
  var app = UiApp.createApplication().setTitle('Feedback');

  var vpMain = app.createVerticalPanel().setWidth('550').setHeight('260');
  app.add(vpMain);

  // рисуем интерфейс
  var lblName = app.createLabel('Пользователь:');
  var lblMail = app.createLabel('E-mail:');
  var lblContent = app.createLabel('Содержание:');
  var txtName = app.createTextBox().setName('txtName').setId('txtName').setWidth(190).setStyleAttribute('border', '1px solid grey');
  var txtMail = app.createTextBox().setName('txtMail').setId('txtMail').setWidth(190).setStyleAttribute('border', '1px solid grey');
  var txtContent = app.createTextArea().setName('txtContent').setId('txtContent').setWidth(325).setHeight(128).setStyleAttribute('border', '1px solid grey');
  var btnSend = app.createButton('Отправить').setWidth('100').setStyleAttribute('margin', '0'); 
  var btnCheck = app.createButton('Проверить').setWidth('100').setStyleAttribute('margin', '0');
  var txtQueryNumber = app.createTextBox().setName('txtQueryNumber').setId('txtQueryNumber').setStyleAttribute('border', '1px solid grey');
  var lblQueryNumber = app.createLabel('Вопрос №:');
  var btnRegister = app.createButton('Регистрация').setWidth(100).setStyleAttribute('margin', '0');
  var lblPhoneNumber = app.createLabel('Телефон: ');
  var txtPhoneNumber = app.createTextBox().setName('txtPhoneNumber').setId('txtPhoneNumber').setStyleAttribute('border', '1px solid grey');

  // имя пользователя и пароль
  var vp1 = app.createVerticalPanel().setStyleAttributes({border: 'solid 1px grey', margin: '5', padding: '5'});
  vpMain.add(vp1);
  var grid1 = app.createGrid(1, 4).setWidget(0, 0, lblName).setWidget(0, 1, txtName).setWidget(0, 2, lblMail)
    .setWidget(0, 3, txtMail).setWidth(530);
  vp1.add(grid1); 

  var hp1 = app.createHorizontalPanel();
  vpMain.add(hp1);

  // отправить
  var vp3 = app.createVerticalPanel()
    .setStyleAttributes({border: 'solid 1px grey', margin: '5', padding: '5'}).setWidth(350);
  hp1.add(vp3);
  
  var grid3 = app.createGrid(3, 1).setWidget(0, 0, lblContent).setWidget(1, 0, txtContent).setWidget(2, 0, btnSend);
  vp3.add(grid3);

  var vp2 = app.createVerticalPanel();
  hp1.add(vp2);

  // регистрация
  var vp4 = app.createVerticalPanel().setStyleAttributes({border: 'solid 1px grey', margin: '5', padding: '5'}).setWidth(182);
  vp2.add(vp4);

  var grid4 = app.createGrid(3, 1).setWidget(0, 0, btnRegister).setWidget(1, 0, lblPhoneNumber).setWidget(2, 0, txtPhoneNumber)
    .setStyleAttribute(0, 0, 'text-align', 'center');
  vp4.add(grid4)

  // проверить
  var vp3 = app.createVerticalPanel().setStyleAttributes({border: 'solid 1px grey', margin: '5', padding: '5'}).setWidth(182);
  vp2.add(vp3);

  var grid2 = app.createGrid(3, 1).setWidget(0, 0, lblQueryNumber).setWidget(1, 0, txtQueryNumber).setWidget(2, 0, btnCheck)
    .setStyleAttribute(2, 0, 'text-align', 'center'); 
  vp3.add(grid2);

  return app;
}
Сохраняем изменения в файле Код.gs, сохраняем версию файла: Файл - Версии - Сохранить новую - ОК.


Публикуем приложение: Публикация - Развернуть как веб-приложение - Кто имеет доступ к приложению: Все, включая анонимных пользователей - Развернуть.

Тестируем приложение: В окне "Развертывание как веб-приложения" нажимаем на ссылку "последнюю версию кода".


В функцию doGet() перед строкой "return app;" добавим код валидации на клиенте:
// валидация на клиенте
  var maxNameLength = 30; // максимум символов имени 
  var validateNotName = app.createClientHandler().validateNotLength(txtName, 1, maxNameLength)
    .forTargets(txtName).setStyleAttribute('border-color', 'red'); 
  var validateName = app.createClientHandler().validateLength(txtName, 1, maxNameLength)
    .forTargets(txtName).setStyleAttribute('border-color', 'grey');
  // валидация E-mail
  var validateNotMail = app.createClientHandler().validateNotEmail(txtMail)
    .forTargets(txtMail).setStyleAttribute('border-color', 'red');
  var validateMail = app.createClientHandler().validateEmail(txtMail)
    .forTargets(txtMail).setStyleAttribute('border-color', 'grey'); 
  var maxNumberLength = 10; // максимум символов номера вопроса
  var validateNotNumber = app.createClientHandler().validateNotLength(txtQueryNumber, 1, maxNameLength)
    .forTargets(txtQueryNumber).setStyleAttribute('border-color', 'red');
  var validateNumber = app.createClientHandler().validateLength(txtQueryNumber, 1, maxNameLength)
    .forTargets(txtQueryNumber).setStyleAttribute('border-color', 'grey'); 
  var maxContentLength = 500; // максимум символов содержания вопроса
  var validateNotContent = app.createClientHandler().validateNotLength(txtContent, 1, maxContentLength)
    .forTargets(txtContent).setStyleAttribute('border-color', 'red');
  var validateContent = app.createClientHandler().validateLength(txtContent, 1, maxContentLength)
    .forTargets(txtContent).setStyleAttribute('border-color', 'grey');

  btnCheck.addClickHandler(validateName).addClickHandler(validateMail).addClickHandler(validateNumber)
    .addClickHandler(validateNotName).addClickHandler(validateNotMail).addClickHandler(validateNotNumber);
  btnSend.addClickHandler(validateName).addClickHandler(validateMail).addClickHandler(validateContent)
    .addClickHandler(validateNotName).addClickHandler(validateNotMail).addClickHandler(validateNotContent);

  var minPhoneLength = 5; // минимальное количество символов номера телефона
  var maxPhoneLength = 20; // максимальное количество символов номера телефона
  var validateNotPhone = app.createClientHandler().validateNotLength(txtPhoneNumber, minPhoneLength, maxPhoneLength)
    .forTargets(txtPhoneNumber).setStyleAttribute('border-color', 'red');
  var validatePhone = app.createClientHandler().validateLength(txtPhoneNumber, minPhoneLength, maxPhoneLength)
    .forTargets(txtPhoneNumber).setStyleAttribute('border-color', 'grey');

  btnRegister.addClickHandler(validateName).addClickHandler(validateMail).addClickHandler(validatePhone)
    .addClickHandler(validateNotName).addClickHandler(validateNotMail).addClickHandler(validateNotPhone);

Сохраняем изменения, обновляем вкладку браузера с интерфейсом приложения и тестим наши кнопки.
Еще немного подредактируем код функции doGet(). После валидации добавим код обработчиков событий нажатия на кнопки.
// на сервере 
  var handlerSend = app.createServerHandler('checkNAddRequests')
    .validateLength(txtName, 1, maxNameLength).validateEmail(txtMail).validateLength(txtContent, 1, maxContentLength).addCallbackElement(vpMain); 
  btnSend.addClickHandler(handlerSend); 

  var handlerCheck = app.createServerHandler('checkNAddRequests')
    .validateLength(txtName, 1, maxNameLength).validateEmail(txtMail).validateLength(txtQueryNumber, 1, maxNameLength).addCallbackElement(vpMain);
  btnCheck.addClickHandler(handlerCheck);

  var handlerRegister = app.createServerHandler('registerUser')
    .validateLength(txtName, 1, maxNameLength).validateEmail(txtMail).validateLength(txtPhoneNumber, minPhoneLength, maxPhoneLength).addCallbackElement(vpMain);
  btnRegister.addClickHandler(handlerRegister);
Теперь необходимо написать сами функции: добавления либо проверки состояния вопроса - checkNAddRequests(), и регистрации пользователя - registerUser().
Функции будут обращаться к таблице по ID (в адресной строке браузера - между "key=" и "#"), поэтому для хранения этого самого ID предлагаю использовать глобальную переменную. Можно использовать свойства скрипта и вытаскивать значение с помощью метода ScriptProperties.getProperty(), но глобальная переменная по-моему лучше, так как у свойств скрипта есть ограничения по количеству обращений (не знаю с чем это связано - вопросы к Google).

Итак, создаем новый файл скрипта: Файл - Создать - Скрипт. Назовем его "Переменные". Удаляем созданную по-умолчанию функцию myFunction() и пишем нашу переменную.
var tableID = '0AkYcK5KeNe1tdHVSV00UWRCSUR3TE02Yk5mUUEUzTk9'; // как-то так
Создадим еще один файл скрипта для хранения функций работы с пользователями, назовем его "Пользователи". Пишем нехитрые функции.
// получаем всех пользователей со свойствами name и mail
function getUsersObject(range) {
  var rowObjects = [];
    for (var i = 1; i < range.length; i++) {
      var row = {name: range[i][1], mail: range[i][2]};
      rowObjects.push(row);
    }
  return rowObjects;
}

// проверяем валидность пользователя
function checkUserIsValid(user, users) {
  for (var i = 0; i < users.length; i++) {
    if (users[i].name == user.name && users[i].mail == user.mail)
      return true;   
  }
  return false;
}

// проверяем существование имени пользователя или E-mail
function checkUserExists(user, users) {
  for (var i = 0; i < users.length; i++) {
    if (users[i].name == user.name || users[i].mail == user.mail)
      return true;   
  }
  return false;
}
После нажатия на кнопки наше приложение будет отображать информационные сообщения.
Создадим еще один файл скрипта, назовем его "Сообщения" и разместим в нем код функций, отображающих сообщения.
// создаем информационную панель для отображения информации об ошибках
function createInfoGrid(app) { 
  var btnOK = app.createButton('OK').setId('btnOK').setWidth('100').setStyleAttribute('border-radius','10px 10px 10px 10px')
    .setStyleAttribute('font-weight', 'bold');
  var infoGrid = app.createGrid(3,1).setId('infoGrid').setWidget(2, 0, btnOK)
    .setStyleAttributes({top:'60', left:'130', position:'fixed', opacity: '0.85'})
    .setStyleAttribute('border-radius','10px 10px 10px 10px').setWidth('300')
    .setStyleAttribute('text-align', 'center').setStyleAttribute('font-weight', 'bold');

  app.add(infoGrid);

  var handlerOK = app.createServerHandler('hideInfoGrid').addCallbackElement(infoGrid);
  btnOK.addClickHandler(handlerOK);
}

// прячем информационное окно
function hideInfoGrid(e) {
  var app = UiApp.getActiveApplication();
  app.getElementById('infoGrid').setVisible(false);
  return app;
}

function msgNotValidUser(app) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid red', background:'Moccasin'});
  app.getElementById('infoGrid').setText(0, 0, 'Пожалуйста, введите Ваше имя пользователя и Ваш E-mail.')
    .setText(1, 0, 'Для работы с системой, пожалуйста, зарегистрируйтесь.')
    .setStyleAttributes({border:'2px solid red', background:'Moccasin'}); 
}

function msgNotValidRequest(app, requestNumber) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid red', background:'Moccasin'});
  app.getElementById('infoGrid').setText(0, 0, 'Информация о вопросе № ' + requestNumber + ' не найдена.')
    .setText(1, 0, 'Если Вы уверены в том, что отправляли и регистрировали вопрос под номером ' + requestNumber +
                 ', пожалуйста, обратитесь к администратору.')
    .setStyleAttributes({border:'2px solid red', background:'Moccasin'}); 
}

function msgError(app, msg1, msg2) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid red', background:'Moccasin'});
  app.getElementById('infoGrid').setText(0, 0, 'В процессе ' + msg1 +' произошла ошибка.')
    .setText(1, 0, 'Пожалуйста, попробуйте ' + msg2 + ' позднее.')
    .setStyleAttributes({border:'2px solid red', background:'Moccasin'});
}

function msgGotRegister(app) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid blue', background:'PaleTurquoise'});
  app.getElementById('infoGrid').setText(0, 0, 'Благодарим Вас за регистрацию.')
    .setText(1, 0, 'На Ваш e-mail отправлено сообщение, содержащее информацию, необходимую для завершения регистрации.')
    .setStyleAttributes({border:'2px solid blue', background:'PaleTurquoise'});
}

function msgGotRequest(app, intRequestNumber) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid blue', background:'PaleTurquoise'});
  app.getElementById('infoGrid').setText(0, 0, 'Ваш вопрос получен и зарегистрирован под номером ' + intRequestNumber + '.')
    .setText(1, 0, 'На Ваш e-mail отправлено сообщение, содержащее номер вопроса.')
    .setStyleAttributes({border:'2px solid blue', background:'PaleTurquoise'});
}

function msgValidUser(app, user) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid red', background:'Moccasin'});
  app.getElementById('infoGrid').setText(0, 0, 'Пожалуйста, введите Ваше имя пользователя и Ваш E-mail.')
    .setText(1, 0, 'Пользователь с именем ' + user.name + ' или адресом E-mail ' + user.mail + ' уже зарегистрирован.')
    .setStyleAttributes({border:'2px solid red', background:'Moccasin'}); 
}

function msgValidUser2(app, user) {
  createInfoGrid(app);
  app.getElementById('btnOK').setStyleAttributes({border:'2px solid red', background:'Moccasin'});
  app.getElementById('infoGrid').setText(0, 0, 'Пожалуйста, проверьте Ваш E-mail.')
    .setText(1, 0, 'Пользователь с именем ' + user.name + ' или адресом E-mail ' + user.mail + ' ожидает подтверждения регистрации.')
    .setStyleAttributes({border:'2px solid red', background:'Moccasin'}); 
}

// создаем информационную панель для отображения информации о найденном вопросе
function createInfoGrid2(app, request) {
  var btnOK = app.createButton('OK').setId('btnOK').setWidth('100')
    .setStyleAttributes({border:'2px solid blue', background:'PaleTurquoise', margin:'0 0 0 10'})
    .setStyleAttribute('border-radius','10px 10px 10px 10px').setStyleAttribute('font-weight', 'bold');

  var txtContent_ = app.createTextArea().setWidth('250').setHeight('65').setText(request.content)
    .setStyleAttribute('background', 'PaleTurquoise').setEnabled(false);
  var txtResponse_ = app.createTextArea().setWidth('250').setHeight('65').setText(request.response)
    .setStyleAttribute('background', 'PaleTurquoise').setEnabled(false); 

  var infoGrid = app.createGrid(7,2).setId('infoGrid').setText(0, 0, 'Вопрос №:').setText(1, 0, 'Дата поступления:')
    .setText(2, 0, 'Статус:').setText(3, 0, 'Дата изменения:').setText(4, 0, 'Содержание:')
    .setText(5, 0, 'Ответ:').setText(0, 1, request.number)
    .setText(1, 1, Utilities.formatDate(new Date(request.dateIn), "GMT+4", "yyyy-MM-dd' 'HH:mm:ss")).setText(2, 1, request.status)
    .setText(3, 1, Utilities.formatDate(new Date(request.dateChanged), "GMT+4", "yyyy-MM-dd' 'HH:mm:ss"))
    .setWidget(4, 1, txtContent_).setWidget(5, 1, txtResponse_)
    .setWidget(6, 1, btnOK)
    .setStyleAttribute(4, 0, 'display', 'inline-block').setStyleAttribute(4, 0, 'vertical-align', 'top')
    .setStyleAttribute(5, 0, 'display', 'inline-block').setStyleAttribute(5, 0, 'vertical-align', 'top')
    .setStyleAttributes({top:'40', left:'80', position:'fixed', opacity: '0.85', border:'2px solid blue', background:'PaleTurquoise'})
    .setStyleAttribute('border-radius','10px 10px 10px 10px').setWidth('400').setStyleAttribute('font-weight', 'bold');

  app.add(infoGrid); 

  var handlerOK = app.createServerHandler('hideInfoGrid').addCallbackElement(infoGrid);
  btnOK.addClickHandler(handlerOK);
}
Для работы с нашими вопросами-ответами нам понадобится еще пара функций. Создадим файл скрипта, назовем его "Вопросы". Пишем функции.
// получаем обект - вопросы со свойствами - заголовками столбцов
function getRequestsObject(range) {
  var rowObjects = []; //объект 
    for (var i = 1; i < range.length; i++) {
      var row = {dateIn: range[i][0], number: range[i][1],
                 user: range[i][2], content: range[i][3], status: range[i][4],
                 dateChanged: range[i][5], response: range[i][6]};
      rowObjects.push(row);
    }
  return rowObjects;
}

// ищем вопрос пользователя
function findRequest(requestNumber, requests, userName) {
  for (var i = 0; i < requests.length; i++) {
    if (requests[i].number == requestNumber && requests[i].user == userName)
      return requests[i];   
  }
}
Создадим еще один файл скрипта, назовем его "Кнопки". Добавим в него функцию очистки текстовых полей.
// очищаем текстовые поля
function clearTxt(app) { 
  app.getElementById('txtQueryNumber').setValue('');
  app.getElementById('txtContent').setValue('');
  app.getElementById('txtPhoneNumber').setValue('');
}
Теперь можно приступать к обработчикам событий нажатия на кнопки. Начнем с добавления либо проверки состояния вопроса. Пишем в тот же файл.
function checkNAddRequests(e) {
  var app = UiApp.getActiveApplication();
  //получаем книгу
  var ss = SpreadsheetApp.openById(tableID);
  // получаем лист по имени
  var shUsers = ss.getSheetByName('Пользователи');
  // получаем диапазон с данными
  var range = shUsers.getDataRange().getValues();
  // получаем пользователей из таблицы
  var users = getUsersObject(range);
  // получаем пользователя из текстовых полей
  var user = {name: e.parameter.txtName, mail: e.parameter.txtMail};
    
  // проверяем пользователя на валидность
  if (!checkUserIsValid(user, users)){
    msgNotValidUser(app); //отображаем сообщение
    return app;
  }

  //получаем лист с сообщениями
  var shRequests = ss.getSheetByName('Сообщения');

  // проверяем источник события нажатия на кнопку
  if (e.parameter.source == 'btnCheck')
    checkRequest(e, shRequests, user, app);  // отправляем в функцию проверки
  else
    addRecord(e, shRequests, user, app); // отправляем в функцию отправки   

  // чистим текстовые поля 
  clearTxt(app);

  return app;
}
В процессе выполнения наша функция проверяет пользователя на валидность и, в случае положительного ответа, вызывает либо функцию проверки состояния вопроса, либо функцию отправки нового вопроса, в зависимости от e.parameter.source - источника события нажатия на кнопку.
Добавляем функцию проверки.
// проверяем состояние вопроса
function checkRequest(e, shRequests, user, app) {
  try {
    //var x = y;
    // получаем диапазон с данными
    var range = shRequests.getDataRange().getValues();
    // получаем вопросы из таблицы
    var requests = getRequestsObject(range);   
    // ищем вопрос
    var request = findRequest(e.parameter.txtQueryNumber, requests, user.name);
    if (!request) {
      msgNotValidRequest(app, e.parameter.txtQueryNumber);
      return;
    } 
    // отображаем информацию
    createInfoGrid2(app, request);     
  
  } catch(e) {   
    Logger.log(new Date() + " " + e);   
    msgError(app, 'проверки вопроса', 'проверить Ваш вопрос');
  } 
}
Сохраняем файл, перезагружаем приложение, проверяем Вопрос №2.

Теперь проверим несуществующий Вопрос №4.
А теперь попробуем ввести несуществующего пользователя.
Наконец, предлагаю раскомментировать строку //var x = y; и проверить как отработает наш код в случае ошибки.

Продолжаем разговор. Закомментируем проверочную строку обратно. Пишем функцию отправки нового вопроса.
function addRecord(e, shRequests, user, app) { 
  try {       
    var intRow = shRequests.getLastRow();     
    // добавляем строку
    shRequests.getRange(intRow + 1, 1, 1, 7).setValues([[new Date(), intRow, user.name, e.parameter.txtContent, 'новый', new Date(), '']])
      .setBackgroundColor('Tomato');   
  
    // отправляем e-mail
    var txt = 'Ваш вопрос получен и зарегистрирован под номером ' + intRow +     
      '.\n\nСпасибо за обращение. ' + 'Пожалуйста, не отвечайте на это сообщение.'             
  
    MailApp.sendEmail(user.mail, 'Вопрос на сайте www.mysupersite.ru', txt,{name: 'noreply@mysupersite.ru', noReply: true});       
  
    msgGotRequest(app, intRow); 
  
  } catch(e) {   
    Logger.log(new Date() + " " + e);   
    msgError(app, 'отправки вопроса', 'отправить Ваш вопрос');
  } 
}
Перед тестированием функции отправки вопроса не мешало бы открыть лист "Пользователи" таблицы TestFeedbackNoForm и изменить e-mail единственного пользователя на e-mail текущего аккаунта Google, так как функция кроме всего прочего отправяет уведомление о регистрации вопроса на e-mail пользователя.
После этого обновляем приложение... и получаем ошибку: "Для выполнения этого действия необходима авторизация.". Это случилось из-за того, что в коде функции addRecord() мы упомянули службу MailApp, а также пытаемся добавить значения в таблицу: shRequests.getRange(intRow + 1, 1, 1, 7).setValues(бла-бла-бла). Можете попробовать закомментировать строку MailApp.sendEmail(бла-бла-бла); и/или shRequests.getRange(intRow + 1, 1, 1, 7).setValues(бла-бла-бла), ничего не изменится - ошибка.

Короче надо авторизовать приложение для работы со службами SpreadsheetApp и MailApp.
Для этого можно выбрать функцию addRecord в списке выбора функций и, например, нажать паучка - "Отладка", или стрелочку - "Выполнить".
Нажимаем на кнопочку "Авторизовать".
Обновляем приложение, отправляем сообщение.
Таким образом мы получили еще один вопрос пользователя в нашу таблицу.
Кроме того мы отправили уведомление на e-mail пользователя о получении и регистрации его вопроса.

Подведем промежуточный итог. На текущем этапе наша система обратной связи вполне работоспособна, за исключением того, что пользователей пока необходимо забивать вручную.

Решаем вопрос. Добавляем в таблицу TestFeedbackNoForm лист, назовем его "Регистрация". Пишем заголовки столбцов.

Регистрацию пользователей реализуем следующим образом: сохраняем данные о пользователе в только что созданном листе "Регистрация", в поле key пишем случайную последовательность символов - ключ регистрации, отправляем на e-mail пользователя ссылку на скрипт подтверждения регистрации.
Займемся этим самым скриптом. Идем на Google Диск - Создать - Еще - Скрипт, назовем его TestFeedbackNoFormReg.
Переименуем созданную по-умолчанию функцию в doGet(e), пишем код функции.
// скрипт регистрации пользователей
function doGet(e) {
  // если нет ключа - выходим
  if (!e.parameter.key)
    return;
  var app = UiApp.createApplication().setTitle('Регистрация').setStyleAttribute('margin', '10px'); 
  var ss = SpreadsheetApp.openById('0AkYcK5KeNe1tdHVSV00UWRCSUR3TE02Yk5mUUEUzTk9');
  var shReg = ss.getSheetByName('Регистрация');
  var range = shReg.getDataRange().getValues();

  var user = getUserObject(range, e.parameter.key);
  // если нет такого пользователя - выходим
  if (!user)   
    return app;

  // добавляем пользователя
  var shUsers = ss.getSheetByName('Пользователи'); 
  shUsers.getRange(shUsers.getLastRow() + 1, 1, 1, 5).setValues([[new Date(), user.name, user.mail, user.phone, user.key]]);
  // удаляем запись 
  shReg.deleteRow(user.rowIndex);
  SpreadsheetApp.flush(); // сохраняем изменения

  // отправляем e-mail
    var txt = 'Благодарим Вас за регистрацию.' +
      '\n\nТеперь Вы можете отправить вопрос, а также контролировать процесс его решения, используя Ваши учетные данные:\n\n' +
      'Пользователь: ' + user.name + '\n' + 'E-mail: ' + user.mail + '\n\n' +       
      'Пожалуйста, не отвечайте на это сообщение.\n';     

  MailApp.sendEmail(user.mail, 'Завершение регистрации на сайте www.mysupersite.ru', txt,{name: 'noreply@www.mysupersite.ru', noReply: true});   

  var hp = app.createHorizontalPanel(); 
  hp.add(app.createLabel('Благодарим Вас за регистрацию.').setStyleAttribute('margin', '1px 5px 0px 0px')); 
  app.add(hp).add(app.createLabel('На Ваш e-mail отправлено сообщение, содержащее Ваши регистрационные данные.'));
  return app;
}
Добавляем в скрипт функцию извлечения пользователя.
// получаем пользователя
function getUserObject(range, key) {
  for (var i = 1; i < range.length; i++) {
    if (range[i][4] == key) {
      var obj = {name: range[i][1], mail: range[i][2], phone: range[i][3], key: range[i][4], rowIndex: i+1};
      return obj;
    }
  } 
}
Сохраняем изменения в файле Код.gs, сохраняем версию файла: Файл - Версии - Сохранить новую - ОК.
Публикуем приложение: Публикация - Развернуть как веб-приложение - Кто имеет доступ к приложению: Все, включая анонимных пользователей - Развернуть.
Тестируем... снова необходима авторизация. В окне редактора скриптов выбираем функцию doGet() и запускаем ее.

Нажимаем "Авторизовать". Обновляем веб-приложение в окне браузера...
"Скрипт был выполнен, но ничего не возвратил.". Что и требовалось, так как мы не прописали в адресной строке ключ.

Открываем лист "Пользователи" таблицы TestFeedbackNoForm, вырезаем данные о нашем единственном пользователе user1 и вставляем их на лист "Регистрация". Добавим ключ 123.

Переходим на вкладку со скриптом регистрации, в адресной строке в самом конце адреса после "/dev" добавляем параметр скрипта - "?key=123".
Адрес должен выглядеть приблизительно так: "https://script.google.com/macros/s/AKfycbykejcoOVMcJJyEyf6feUjLndcA172Lv86AFKk-_2Y/dev?key=123"
Обновляем страницу.

Возвращаемся в таблицу TestFeedbackNoForm. Наш пользователь исчез с листа "Регистрация" и вернулся на лист "Пользователи". В поле "Ключ" у него появилось значение - "123".
Проверяем почту.
Примерно такое же сообщение будут получать наши пользователи после завершения регистрации.

Остается добавить в скрипт TestFeedbackNoForm функцию обработчик события нажатия на кнопку "Регистрация", которая будет формировать ссылку на скрипт регистрации со случайным ключем, отправлять эту ссылку пользователю и одновременно добавлять данные о регистрации на лист "Регистрация" таблицы TestFeedbackNoForm.

Для того, чтобы сформировать правильную ссылку, которая будет общедоступна, переходим в редактор кода скрипта регистрации TestFeedbackNoFormReg - Публикация - Развернуть как веб-приложение, копируем ссылку из поля "Текущий URL веб-приложения" и сохраняем ее в каком-нибудь блокноте.
Адрес должен выглядеть приблизительно так: "https://script.google.com/macros/s/AKfycmPby5uqw6D9S0Xl5JN77pPbYBCYJtbChnYZLlgqLH7W6ibUKSY/exec". Что называется "почувствуйте разницу", между ссылкой для разработки  и ссылкой на рабочее приложение.

Возвращаемся в редактор кода скрипта TestFeedbackNoForm, открываем файл Кнопки.gs, пишем функцию.
function registerUser(e) {
  var app = UiApp.getActiveApplication(); 
  try {   
    //получаем книгу
    var ss = SpreadsheetApp.openById(tableID);
    // получаем лист по имени
    var ssUsers = ss.getSheetByName('Пользователи');
    // получаем диапазон с данными
    var range = ssUsers.getDataRange().getValues();
    // получаем пользователей из таблицы
    var users = getUsersObject(range);
    // получаем пользователя из текстовых полей
    var user = {name: e.parameter.txtName, mail: e.parameter.txtMail};   
  
    // проверяем существование имени пользователя или E-mai в списке зарегистрированных пользователей
    // на листе "Пользователи", если такой уже есть - выходим
    if (checkUserExists(user, users)){
      msgValidUser(app, user); //отображаем сообщение     
      return app;
    }
      
    var shReg = ss.getSheetByName('Регистрация');
    range = shReg.getDataRange().getValues();
    var usersReg = getUsersObject(range);
  
    // проверяем существование имени пользователя или E-mai в списке пользователей, ожидающих подтверждения регистрации
    // на листе "Регистрация", если такой уже есть - выходим
    if (checkUserExists(user, usersReg)){
      msgValidUser2(app, user); //отображаем сообщение     
      return app;
    }
  
    // получаем случайную строку
    var randomString = getRandomString();
    // пишем данные пользователя, ожидающего подтверждение регистрации на лист "Регистрация"
    shReg.getRange(shReg.getLastRow() + 1, 1, 1, 5)
      .setValues([[new Date(), e.parameter.txtName, e.parameter.txtMail, e.parameter.txtPhoneNumber, randomString]])
  
    // формируем строку
    var txt = 'Благодарим Вас за регистрацию.' +
      '\n\nДля завершения процесса регистрации, пожалуйста, перейдите по ссылке:\n' +
      'https://script.google.com/macros/s/AKfycmPby5uqw6D9S0Xl5JN77pPbYBCYJtbChnYZLlgqLH7W6ibUKSY/exec?key=' + randomString +
      '\n\nЕсли переход не работает, пожалуйста, скопируйте приведённую выше ссылку в адресную строку браузера.\n\n' +
      'Ссылка будет действительна в течение часа.\n\nПожалуйста, не отвечайте на это сообщение.'   
    // отправляем e-mail
    MailApp.sendEmail(user.mail, 'Подтверждение регистрации на сайте www.mysupersite.ru', txt,{name: 'noreply@www.mysupersite.ru', noReply: true});
  
    // чистим текстовые поля
    clearTxt(app);
  
    // отображаем сообщение
    msgGotRegister(app);
  
  } catch(e) {

    Logger.log(new Date() + " " + e);
    msgError(app, 'регистрации', 'зарегистрироваться');
  }

  return app;
}
Не забываем добавить функцию формирования случайной строки. На мой взгляд 33-х знаков вполне достаточно.
function getRandomString() {   
  var chars = "ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz1234567890_-";
  var sLength = 33; 
  var rString = '';
    for (var i=0; i<sLength; i++) {
      var rnum = Math.floor(Math.random() * chars.length);
      rString += chars.substring(rnum,rnum+1);
    } 
  return rString;
}
Обновляем веб-приложение TestFeedbackNoForm, пытаемся зарегистрировать пользователя user1.

Законное возражение. Открываем лист "Пользователи" нашей таблицы, удаляем информацию о пользователе user1, возвращаемся на вкладку приложения, нажимаем на кнопку "Регистрация" еще раз.
Проверяем лист "Регистрация" таблицы TestFeedbackNoForm.
Проверяем почту.
Все в елочку. В третий раз пытаемся зарегистрировать пользователя user1.

Что и следовало ожидать. Возвращаемся в только что полученное почтовое сообщение, переходим по ссылке.
Открываем лист "Регистрация" нашей таблицы - данные исчезли. Открываем лист "Пользователи".
Проверяем почту.

В завершение напишем функцию, которая будет удалять истекшие запросы на регистрацию. Открываем редактор скриптов таблицы TestFeedbackNoForm: Инструменты - Редактор скриптов, создаем файл скрипта: Файл - Создать - Скрипт. Назовем его Триггер. Пишем код функции.
// удаляем истекшие запросы на регистрацию
function clearInvalid() {
  var sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Регистрация');
  var lastRowIndex = sh.getDataRange().getLastRow() + 1; 
  var currentDate = new Date();
  for (i=1; i<lastRowIndex; i++) {
    if (parseInt((currentDate.getTime() - new Date(sh.getRange(i, 1).getValue()).getTime())/60000) > 60) {     
      sh.deleteRows(i);
      SpreadsheetApp.flush(); // сохраняем изменения
      i--;
    }
  } 
}
Как вы наверняка догадались, затем необходимо добавить триггер: Ресурсы - Триггеры текущего проекта - Добавить триггер - Выбираем из списка функцию clearInvalid() - Динамический - Часовой таймер - Каждый час - Сохранить.


Таким образом запросы на регистрацию, существующие дольше часа будут удаляться.
Очевидно, что утверждая в e-mail сообщении о том, что ссылка будет действительна в течение часа, мы немного лукавим, так как, принимая во внимание свойства созданного триггера, иногда ссылка может оказаться валидной и почти через два часа. В случае необходимости очищать лист "Регистрация" чаще, вопрос решается понятным образом.

Feedback готов. Осилившим дорогу респект. За сим позвольте откланяться.