Сложные интерфейсы на javascript вместе Yahoo UI. Часть 18

February 16, 2009

Эта статья завершит рассказ об одном из самых “больших” и полезных компонентов в библиотеке Yahoo UI - компоненте DataTable. DataTable служит для отображения на веб-страницах информации в форме таблиц. В последних двух статьях я рассказал почти обо всех возможностях DataTable. Остались не раскрытыми только те функции DataTable, которые связаны с редактированием содержимого таблицы.

В прошлой статье я рассказывал, как можно настроить правила выделения строк в таблице. Как разрешить выделять одну строку, или целый их диапазон, как реализовать динамическую подсветку строки, над которой в этот момент времени находится курсор. Все это было подготовительными шагами для того, чтобы превратить DataTable из средства только отображения табличных данных в инструмент, позволяющий редактировать данные в таблице и даже отправлять информацию назад на сервер (сохранять ее в БД).

Разрешив пользователю выделять в конкретный момент времени только одну строку, я хочу реализовать модель редактирования содержимого таблицы с помощью специальной (моей) панели. Эта панель (состоящая из множества текстовых полей, наборов радио-кнопок, падающих списков) будет расположена ниже самой таблицы с записями. Как только пользователь выделит любую запись в таблице, то элементы управления панели редактирования будут наполнены информацией. А когда пользователь переходит (выделяет) другую запись в таблице, то необходимо изменить внешний вид таблицы и отправить запрос сохранения отредактированной записи на сервер. Каждый из этих шагов имеет свои подводные камни, и может превратиться не в одну сотню строк кода, если делать все качественно. К примеру, должно ли редактирование содержимого некоторого поля на панели внизу таблицы приводить к одновременному изменению содержимого соответствующего столбца таблицы? Следует ли блокировать навигацию пользователя по таблице до тех пор, пока отправленный запрос сохранения исправленной записи на сервер не вернулся с подтверждением “да, все правки были сохранены”. Или, может быть, нужно ставить запрос на сохранение в асинхронную очередь, так чтобы пользователь продолжал работу, а мы бы тем временем могли бы накопить “пачку правок”, чтобы послать их на сервер все вместе. Такой вариант имеет смысл в случае, если количество правок очень велико, равно как и затраты времени на их обработку. Где выполнять валидацию введенных пользователем данных на предмет их корректности, как ее грамотно разделить между клиентом и сервером, так чтобы не нагружать сервер множеством “мелких” запросов, возникающих по мере того, как пользователь редактирует информацию? Как быть если некоторые из полей записи представлены в виде падающих списков, значения которых берутся из базы данных? Следует ли кэшировать эти списки на время всего сеанса работы, а может перезагружать с каким-то интервалом времени или при начале редактирования очередной записи? Следует ли после отправки запроса на сохранение данных на сервер, загружать с него же обновленный перечень записей (то, что могло быть параллельно отредактировано другим пользователем) или изолировать сеансы разных пользователей друг от друга?

Я оставлю все это за кадром и сосредоточусь на ключевых особенностях, именно, YUI: о том, как назначить обработчик события “выделена строка”, как узнать то, какая строка выделена, как извлечь из нее информацию для всех колонок и как “на лету”, без перезагрузки DataTable, изменять содержимое ячеек.

Начнем с простого: создадим заготовку панели редактирования записи. Это будут четыре текстовых поля с идентификаторами (id), имена которых совпадают с именами полей таблицы:
  1. <div id="editorPanel">
  2.    fio: <input type="text" id="fio" /> <br />
  3.    birthday: <input type="text" id="birthday" /> <br />
  4.    sex: <input type="text" id=" sex" /> <br />
  5.    salary: <input type="text" id="salary" /> <br /> 
  6. </div>
Следующим шагом я создам функцию, которая будет получать извещения о том, что пользователь выделил какую-то строку таблицы. В прошлой статье я рассказал о, казалось бы, подходящем событии “rowClickEvent”, которое “выбрасывается” всякий раз при клике мышью на строке таблицы. Увы, но она нам не подходит, т.к. выделение может меняться не только мышью, но и по нажатию кнопок “вверх” и “вниз”. Нам поможет более общее событие “rowSelectEvent”:
  1. table.subscribe("rowSelectEvent", onSelectRow);
Функция, обрабатывающая события “onSelectRow”, устроена очень просто: в качестве параметров она получает ссылку на объект “el” (строка таблицы) и объект “record”. Внутри которого хранится ассоциативный массив со всеми полями записи. Осталось только найти на html-странице текстовые поля, соответствующие каждой из колонок таблицы, и наполнить их содержимым.
  1. function onSelectRow (oArgs){
  2.  var el = oArgs['el'];
  3.  record = oArgs['record'];
  4.  YAHOO.util.Dom.get('fio').value = record.getData ('fio');
  5.  // и так для остальных полей 
  6. }
Результат выполнения примера показан на рис. 1.



Пример не идеален, т.к. при переходе по страницам paginator-а, событие “запись была изменена” не генерируется и, следовательно, панель редактирования отображает устаревшие данные. В следующем примере я подписываюсь на сообщение “renderEvent”, которое выбрасывается всякий раз, когда таблица перерисовывается (при начальной загрузке данных в DataTable, а также при переходе по страницам, изменении направления сортировки). Устройство же функции “onRender” тривиально: я получаю ссылки на всех текстовые поля в панели редактирования и очищаю их.
  1. table.subscribe("renderEvent", onRender);
  2.  
  3. function onRender (oArgs){
  4.   YAHOO.util.Dom.get('fio').value = '';
  5.   // и так все остальные поля 
  6. }
Рассказанного мною уже должно хватить на реализацию простенькой редактируемой DataTable, выполняющей асинхронную отправку изменении на сервер. Т.е. внутри функции, обрабатывающей событие “rowSelectEvent”, необходимо проверить условие, что ранее уже была выбрана запись и то, что те значения, которые сейчас находятся в текстовых полях панели редактирования, отличаются от оригинальных значений записи таблицы. Если это так (запись была изменена), то нужно сформировать ajax-запрос на сервер (об использовании функции YAHOO.util.Connect.asyncRequest я рассказывал в статье № 5). Недостаток подобного алгоритма очевиден: в случае, если асинхронно выполняемая операция сохранения была неудачна, то пользователь уже перешел на другую запись и, следовательно, потерял все свои правки. Давайте, лучше заблокируем DataTable до тех пор, пока с сервера не придет ответ, подтверждающий завершение операции сохранения. К сожалению, среди всего множества событий, “выбрасываемых” DataTable нет события “до перехода на новую запись”. Такого события, чтобы мы могли внутри функции обработчика “до перехода на другую запись” проверить ряд условий и отменить (блокировать) переход пока сохранение не будет завершено. С другой стороны, реализовать блокировку перехода на запись легко с помощью следующего алгоритма:
  1. var lastSelected = null;
  2. var requstedToSelectAfterSaving = null;
  3.  
  4. function onSelectRow (oArgs){
  5.  var dom = YAHOO.util.Dom;
  6.  var mustLock = false;
  7.  record = oArgs['record'];
  8.  if (requstedToSelectAfterSaving != null) return;
  9.  if (lastSelected != null){
  10.    oldData = this.getRecord (lastSelected).getData();
  11.    mustLock = ( 
  12.         dom.get('fio').value != oldData['fio'] || 
  13.         dom.get('birthday').value != oldData['birthday'] || 
  14.         dom.get('sex').value != oldData['sex']  || 
  15.         dom.get('salary').value != oldData['salary']); 
  16.  }
  17.  
  18.  if (mustLock){
  19.    requstedToSelectAfterSaving = this.getLastSelectedRecord (); 
  20.    alert ('saving data'); // имитация сохранения данных
  21.    var  lastSelected2 = lastSelected;
  22.    lastSelected = null;
  23.    this.unselectAllRows ();
  24.    this.selectRow ( lastSelected2 );
  25.    this.disable (); }
  26.  else{
  27.   dom.get('fio').value = record.getData ('fio');
  28.   dom.get('birthday').value = record.getData ('birthday');
  29.   dom.get('sex').value = record.getData ('sex');
  30.   dom.get('salary').value = record.getData ('salary');
  31.   lastSelected = this.getLastSelectedRecord ();
  32.  } 
  33. }
Код кажется большим и сложным, но на самом деле, все построено вокруг двух переменных lastSelected и requstedToSelectAfterSaving. У нас есть две записи: “старая” и “новая”. Изначально была выделена “старая” запись, а ее идентификатор сохранен в переменную lastSelected. Теперь пользователь хочет выделить, т.е. перейти на “новую” запись. Проверим, можно ли это пользователю разрешить, или переход нужно блокировать на время сохранения? Функция обработчик события “выделена запись” сначала проверит условие, что ранее была выделена хоть какая-то запись. Затем я с помощью функции this.getRecord (lastSelected) получаю из DataTable содержимое этой записи (“старая” запись). Сверяю значения полей “старой записи” с теми значениями, которые введены в текстовые поля. Если пользователь ничего не отредактировал (значения совпали), то я наполняю текстовые поля панели редактирования новыми данными и сохраняю как “старую” запись ту, на которую только что был выполнен переход (getLastSelectedRecord). В противном случае, последовательность шагов сложнее. В переменную requstedToSelectAfterSaving я сохраняю номер записи, на которую пользователь хочет перейти (“новая” запись). Ее номер мне нужен для двух вещей. Во-первых: когда я завершу ajax-вызов, сохраняющий данные на сервер (в примере он имитируется вызовом alert), то было бы неплохо завершить переход на ту запись, на которую пользователь и хотел перейти (“новую запись”). Второе назначение переменной requstedToSelectAfterSaving заключается в блокировке бесконечной рекурсии. Которая неизбежно возникнет, если я из обработчика события “была выделена другая запись”, попробую выделить запись, ведь это в свою очередь должно привести к выбрасыванию события “запись была изменена”. Значит мне внутри функции обработки события “выделена запись” нужно четко разграничивать два случая: запись была выделена пользователем, или запись была выделена программно, из javascript, в ходе операции сохранения изменений в DataTable. Зачем мне нужно выделять еще какую-то запись? Прежде всего, не “какую-то” запись, а “старую запись”. Т.к. YUI извещает нас о том, что выполняется переход на запись фактически после того, как он уже состоялся, то нам нужен механизм “отката изменений”, т.е. нужно вернуться (выделить) старую запись. Если это не сделать, то в случае, когда при сохранении изменений произошел сбой, может возникнуть несогласованность интерфейса: когда визуально в DataTable выделена (подсвечена) запись, не имеющая никакого отношения к той, которую не удалось сохранить. После того как отработает ajax-запрос, я снимаю текущее выделение и снова выделяю предыдущую запись (ведь ее номер мы ранее сохранили). Завершающим штрихом будет, визуальное изменение DataTable, например, ее цвета на серый “disabled” и запрет на любые действия с таблицей. Для этого служит (правда, не слишком удачно) вызов метода disable. Почему метод disable работает не удачно? Хотя внешний вид таблицы изменился, равно, как и было заблокировано выделение записей, сортировка столбцов, изменение их ширины (для того, чтобы вернуть DataTable в рабочее состояние используйте вызов enable). Так вот, проблема в том, что если таблица использует paginator для отображения большого количества записей, то внешний вид и функциональность paginator-а никак не изменятся от применения disable на “родительском” компоненте DataTable. Получается довольно забавный эффект показанный на рис. 2.



Больших проблем этот “бажок” YUI не вызывает, т.к. на момент отправки запроса обойтись просто затемнением DataTable не получается: не красиво и не понятно клиенту, что там происходит с интерфейсом. Я предпочитаю делать так: помещаю и DataTable и paginator внутрь общего блока div. Затем, перед началом отправки запроса, я меняю стилевое оформление контейнера div так, чтобы создать эффект затемнения, закрывающий и DataTable и paginator, а посередине div-а размещается gif картинка с анимацией, например, часов, подсказывающая о том, что сейчас идет выполнение запроса сохранения данных и нужно немного подождать. Теперь, предположим, что запрос сохранения отработал, и никаких ошибок при этом не возникло. Какая информация будет возвращаться из php-скрипта, обслуживающего DataTable, решается в каждом случае индивидуально. Самый простой вариант, когда javascript код в браузере просто получает одно из двух значений: либо “saved”, либо “failed” (была операция сохранения удачной или нет). В некоторых случаях вместе с признаком успешности завершения операции возвращается содержимое записи. Казалось бы, зачем, ведь те же самые данные мы отправили минутой назад на сервер для сохранения? Для “больших” систем характерно хранение данных в таблицах обслуживаемых триггерами или хранимыми процедурами. Триггер вызывается при выполнении операций сохранения записей в таблице и может данные изменить перед их действительным сохранением. А это значит, что данные, которые “ушли” на сервер, могут быть совсем не теми, что были сохранены в таблицах БД. Еще более сложные действия предстоят, когда пользователь изменил значение поля, к которому применена сортировка, например, сменил ФИО сотрудника с “Иванов” на “Сидоров”. В этом случае отредактированная запись не должна отображаться на текущей странице DataTable: нам нужно вернуть с сервера сразу 10 записей (содержимое пересортированной страницы). Не уходя далеко от рассмотрения YUI DataTable, подумаем над тем, как динамически изменять информацию, отображаемую в таблице, без ее полной перезагрузки. Это пригодится не только для приведения внешнего вида таблицы в соответствие с тем, что находится на сервере в таблице БД после сохранения записи, но и для реализации функции “живого редактирования”. Т.е. по мере ввода пользователем нового значения для какого-то из полей таблицы в текстовом поле панели редактирования, внешний вид связанной с этим полем колонки также должен меняться. Делается это очень просто: назначим каждому из текстовых полей в панели редактирования функцию, реагирующую на изменения такого текстового поля:
  1. YAHOO.util.Event.addListener("fio", "keyup", onChangeFIO );
Функция onChangeFIO вызывается всякий раз, когда содержимое текстового поля изменяется при нажатии любой из клавиш (хотя и не вызывается, если, например, выделить часть содержимого текстового поля и используя контекстное меню вырезать). Есть несколько способов изменить информацию в DataTable, например, так:
  1. var data = table.getRecord(lastSelected).getData();
  2. data ['fio'] = YAHOO.util.Dom.get('fio').value; 
  3. table.updateRow (lastSelected, data);
Здесь и далее предполагается что lastSelected это идентификатор той записи, значение поля “fio” для которой мы и хотим поменять. Функции updateRow получает первым параметром номер записи подлежащей изменению, а второй параметр задает ассоциативный массив (объект) с новыми значениями полей. Внешний вид ячеек строки незамедлительно будет изменен. Небольшой недостаток использования updateRow в том, что будут выброшены события “таблица была изменена”, которые могут быть не готовы к этому. Есть и второй прием, в котором мы изменяем два объекта: сначала RecordSet привязанный к DataTable (так, чтобы если в дальнейшем мы запросим для записи значения ее полей, то получить их измененные величины). И вторым шагом, нужно изменить внешний вид ячейки таблицы:
  1. var reqTd = {record:lastSelected, column: table.getColumn ('fio') }
  2. table.getTdLinerEl(reqTd).innerHTML = YAHOO.util.Dom.get('fio').value;
  3. table.getRecord(lastSelected).setData('fio', YAHOO.util.Dom.get('fio').value);
  4. table.getRecordSet().updateRecordValue  (selected, 'fio',  YAHOO.util.Dom.get('fio').value);
Первой строкой я создал объект, хранящий номер записи и ссылку на колонку “fio”. Вызов метода getTdLinerEl возвращает ссылку на ячейку таблицы, значение которой (innerHTML) я хочу изменить. Естественно, что в этом случае я теряю возможности по автоматическому форматированию содержимого ячейки в зависимости от ее типа данных, которые предоставляет YUI (formatter-ы). Третья и четвертая строка служат для изменения содержимого RecordSet-а и по своему поведению идентичны. Какой подход использовать с раздельным изменением ячеек DataTable или с помощью функции updateRow – решать вам.

Я хочу завершить рассказ об DataTable, показав как можно выполнять редактирование ячеек таблицы “inline”. Т.е. при выделении ячейки таблицы, ее внешний вид меняется и на экране показывается компонент редактор (текстовое поле, падающий список). Привязать к любой из колонок таблицы специализированный редактор очень просто. Когда мы создаем массив объектов, описывающий характеристики колонок таблицы, то наряду с такими уже знакомыми нам свойствами как функция форматирования содержимого ячейки, признак того можно ли сортировать колонку, мы можем указать свойство editor (редактор ячейки). В следующем примере (результат выполнения показан на рис. 3) я показываю пример с редактором полей типа “дата-время”, редактор поля в виде диалогового окна с двумя переключателями радио-кнопками для выбора пола сотрудника.



Есть также редактор в виде диалогового окна с набором checkbox-ов, с выпадающим списком вариантов и многое другое:
  1. // как обычно описываем колонки таблицы 
  2. var columns = [ 
  3. {key:"fio", sortable:true}, 
  4. {key:"birthday", sortable:true, formatter:YAHOO.widget.DataTable.formatDate, editor: new YAHOO.widget.DateCellEditor() }, 
  5. {key:"sex",  editor: new YAHOO.widget.RadioCellEditor({radioOptions:["male","female"], disableBtns:true}) }, 
  6. {key:"salary",  sortable:true} 
  7. ];
  8.  
  9. // а по выделению ячейки нужно показать редактор ячейки
  10. table.subscribe("cellClickEvent", table.onEventShowCellEditor);