Постановка задачи


В процессе доработки существующей административной страницы на «самописном» движке возникла необходимость замены грубых стандартных модальных диалоговых окон на окна вписывающиеся в дизайн сайта. Переписывать административную часть никто не позволит, да и нет в этом никакой необходимости. Основное условие — быстрая интеграция в действующий код.

Поэтому принято решение выполнить косметическую операцию.

Итак, сформулированы следующие требования:

  • реализовывать на jQuery 1.9,
  • вызов аналогично стандартным окнам для быстрой замены кода,
  • вложенность диалоговых окон 2-3 уровня,
  • заменить диалоговые окна типов confirm и alert.

Первым делом обратился к поиску в Google. Имеющиеся разработки мне не подошли, т.к. хотелось по максимум сохранить синтаксис вызова…

if(confirm('') ) {...}

или предлагали дописывать достаточно объёмные фрагменты кода в виде дополнительные функций, описывающих что именно будет происходить после того или иного выбора в окне (например Dialog UI).

В процессе разбора задачи выявил основные проблемы:

  • точка вызова генерирует диалоговое окно из функции,
  • возврат после выбора элемента управления должен осуществляться на следующую строку после точки вызова,
  • при этом функция, генерирующая html-код диалогового окна уже завершила выполнение.

Главный вопрос — как вернуться на то место в коде, которое уже проскочил?

Задача стала выглядит следующим образом:

  • Остановить выполнение функции.
  • Сформировать диалоговое окно.
  • Дождаться выбора пользователя (при этом неизвестно когда это произойдёт).
  • Обработать вызов пользователя.
  • Продолжить выполнение с точки вызова.

Реализовать такое на jQuery, по крайней мере для меня, выглядит довольно затруднительной задачей.

Принцип решения


В качестве решения были опробованы функции обратного вызова или таймеры для перехвата момента выбора.

Наилучший результат по данной задаче я получил немного изменив промежуточные условия задачи и реализовав следующий принцип:

  • при генерации диалогового окна выполнение прерываем,
  • после выбора в диалоговом окне запускаем функцию заново,
  • а в точке вызова диалогового окна если выбор в текущей «сессии» уже был сделан,
  • проходим условие «транзитом» и возвращаем выбор в скрипт.

Таким образом формирование каждого диалогового окна — это одна итерация, одно звено «транзитной сессии». Обработчик вызывается-дважды — первый раз генерируется само окно, второй раз проходит транзитом до следующего условия.

Однако если диалоговых окон в пределах одной вызывающей функции несколько, т.е. они имеют вложенность, формируется целая «транзитная цепочка». В каждой итерации — по 2 вызова функции. И с каждым новым диалоговом окне в последовательности количество вызовов функции-обработчика удваивается. Не думаю, что когда-либо потребуется вкладывать десятки окон, поэтому накладные расходы ресурсов браузера клиента расцениваю как минимальные.

Напоминает рекурсию, но отличается тем, что:

  • в одной итерации функция запускается дважды,
  • с каждой итерацией не выполняется «копия» функции, а происходит как-бы пошаговое проталкивание, как бы «накатывается снежный ком», пока условия функции не выполнятся полностью.

Результаты выбора удобно сохранять в привязке к элементу DOM, инициировавшему вызов диалога в виде атрибутов data-, нестандартных атрибутов или в виде именованных данных с помощью функции .data().

Данному принципу присвоил рабочее название «транзитно-диалоговых» или «транзитных» вызовов.

В моём примере реализовано в виде плагина jQuery.
Код плагина с примером вызовов выложен здесь.

По мере разработки столкнулся со следующими проблемами:

Проблема №1)

Т.к. диалоговые окна могут быть вложенными, придётся сохранять состояние каждого окна. Для этого необходимо ввести идентификатор окна.

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

Ответы сохраняются для каждой кнопки-инициализатора диалога, так что мы получаем некое «транзитное пространство имён», благодаря чему можем в каждой функции использовать повторяющиеся id окон. Я использую 1,2, и так далее.

Проблема №2)

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

Решение:

Для этой цели введён флаг (у меня jdReclick). Параметр присваивается кнопке перед каждым повторным вызовом и удаляется сразу же после обработки повторного вызова. Ориентируясь на данную метку, удаляем все-данные «транзитной сессии» если:

  1. было обработано-последнее окно в функции,
  2. в одном из окон была выбрана отмена

Проблема №3)

Как отличить последнее это окно в вызывающей функции или нет. Если окно последнее, мы имеем право удалить все данные «транзитной сессии» чтобы при повторном нажатии на кнопку алгоритм запускался заново.

Препятствия:

  • В вызывающем скрипте нет возможности заглянуть за точку запуска и посмотреть есть ли там ещё диалоговые вызовы.
  • Если используется ветвление, в разных условиях может быть разное кол-во окон.

Варианты решения:

  • Регистрировать диалоговые окна в начале скрипта и передавать данный реестр в обрабатывающий скрипт.
  • В каждом вызове передавать метку является ли окно финальным.
  • После обработки последнего окна отдельным вызовом запускать очистку «транзитной сессии». Во всех случаях имеются дополнительные параметры, которые нужно помнить и не перепутать, это также является некоторым неудобством. Я совместил метку и id окна, зарезервировав 0 в качестве флага отмены. Если заранее неизвестно будет ли запущено ещё одно окно в транзитной цепочке, т.к. это зависит от выбора пользователя, в условии где окон больше не будет, просто прописываем принудительную очистку «транзитной сессии».

Теперь детально о реализации в моём примере


Событие на элементе запускает функцию-инициатор «транзитно-диалоговой» цепочки:

$('#test').click(function() { ...

Собственно запуск диалогового окна выглядит так:

$(this).jdDialogs('confirm',1,['Текст?','Заголовок'],fncname)

Для привязки данных к элементу, необходимо передать в плагин селектор this,
в атрибутах передаём:

1 — тип окна (имя метода плагина),
2 — id окна
3 — текстовые параметры окна
4 — функция обратного вызова

Обработка результатов можно реализовать несколькими способами:


if(! $(this).jdDialogs('confirm',1,['Текст?','Заголовок']) ) return;

if( $(this).jdDialogs('confirm',1,['Текст?','Заголовок']) ) {
...
}

switch( $(this).jdDialogs('confirm',1,['Текст?','Заголовок']) ) {
 case 1: ...;
 default: return;
}

Если после вызова Alert есть выполняющийся кода, придётся использовать return, если нет — return можно опустить.


$(this).jdDialogs('alert',0,['Сделано!','Project'])


if(! $(this).jdDialogs('alert',0,['Сделано!',project]) ) return;
alert('Код выполнен');

В плагине предусмотрены стандартные методы confirm, alert, их краткие алиасы cnf, al для сокращения записи. Можно дописать собственные вызовы.

Все вызовы запускают универсальный метод jdDialog, в котором:

  • распознаётся клик клиента или повторный «транзитный» вызов
  • для «транзитного» вызовы возвращается сохранённое значение выбора
  • если окно запускается впервые — запускается генерация самого окна jdGetWin
  • генерируется id элемента управления если не было указано — метод jdCheckId

В данном методе можно изменить/дописать новые условия case для формирования своего набора кнопок, а также в return вывести отдельный отличный от остальных шаблон.

Клик на кнопки обрабатывают привязанные события. Для alert предложено 2 варианта закрывающей кнопки — jdClose0 с отменой и jdClose1 — с подтверждением. Какую выставить настраивается в jdGetWin в switch case.

Событие переадресовывается на метод jdSetAnswer. В методе распознаётся id текущего окна и id элемента управления-инициатора запуска диалогового окна. Зная id кнопки, можем сохранить результат выбора с ключом по id окна в «транзитную сессию».

$(id).data(fname,value);

Далее уничтожаем окно с помощью .detach() с анимационным эффектом например fadeIn 10

$('.jdModalBg').detach().fadeIn(10,function() {	

В функции обратного вызова проверяем: если отмена — сбрасываем «транзитную сессию». В этом методе если при вызове диалогового окна 4-м параметром была передано имя функции, функция вызывается.

if(!!fncdo) window[fncdo]();

Затем запускается транзитный вызов. Передаём ID элемента управления — инициатора для повторного клика по нему. Т.е. эмулируется клик по элементу управлению — инициатору диалога.

methods.jdReclick(id);

В моём примере довольно просто дописать произвольные конструкции с вызовом и обработкой окон.

Пример реализации трёх-кнопочного окна


1. В вызове в data добавляем ещё 2 параметра: надписи на двух кнопках вместо «Ок».

$(this).jdDialogs('confirm2bttn',0,['Мы на перепутье','Действие шаг 3','Идти налево','Идти направо'])

Использование массива с текстами позволяет гибко управлять количеством параметров — здесь нужно просто дописать ещё два параметра в массив.

2. Подключаем вызов:

	confirm2bttn : function(fid,data,fname) {
		return methods.jdDialog('Confirm2bttn',fid,data,$(this),fname);
		}	

3. Подключаем обработку вызова. Сам шаблон оставляем старый, меняем только кнопки:

case 'Confirm2bttn':
	var bttntext1 = data[2];
	var bttntext2 = data[3];
	jdBttns = '<button class="jdOk jdOk1">'+bttntext1+'</button>'+
			 '<button class="jdOk jdOk2">'+bttntext2+'</button>'+
			 '<button class="jdCancel">Отмена</button>';
	clClass = 'jdClose0';
break;

4. Добавляем событие на кнопку Ok2 чтобы различать нажатие кнопок — транзитный вызов при нажатии на .jdOk2 теперь будет возвращать значение 2:

	.on('click','.jdOk2', function() {			
		methods.jdSetAnswer(2,$(this));
		})

5. Возвращаемся в скрипт-инициатор и прописываем условия для разных кнопок:

switch($(this).jdDialogs('confirm2bttn',0,['Мы на перепутье','Действие шаг 3','Идти налево','Идти направо'])) {
	case 0: return;
	case 1:
		alert('Идём налево');
	break;
	case 2:
		alert('Идём направо');
	break;
	default:

6. Ну и можно присвоить элементам нового окна новый стиль, например сделать зелёным с жёлтым текстом. Как-то так:

.jdDialogConfirm2bttn {
	min-width:380px;
	max-width:450px;
	}
.jdDialogConfirm2bttn .jdText {
	min-height:60px;
	}	
.jdDialogConfirm2bttn .jdHeader{
	background-color: hsl(115,63%,15%);
	color:#F0C800;
	}
.jdDialogConfirm2bttn .jdHeader .jdClose{
	background-color: hsl(114,58%,22%);
	color:#F5DA50;
	}

Предполагаю, что использование принципа «транзитных вызовов» предоставляет способ решения проблем, связанных с ожиданием действий от клиента. При этом достаточно использовать библиотеку jQuery с предлагающимся расширением. Представленный полностью функциональный плагин разрабатывался для использования с библиотекой jQuery версии 1.9, работает также с наиболее свежей на момент написания статьи версией 3.2.1.
Поделиться с друзьями
-->

Комментарии (22)


  1. Akuma
    27.06.2017 17:41
    +3

    Эпоха плагинов для jQ прошла уже… ну вобщем давно :)

    Плюс, вы никак не сделаете замену confirm() на кастомных окошках. Вот вообще никак, т.к. всплывающие html-окна не будут блокировать исполнение кода. То, что реализовано в статье — обычный callback.

    Максимально приближенный вариант будет что-то вроде:

    async function() { // Здесь выполняется наш код. Да, в async
        if (await jdDialog(...)) { // Ваша функция должна вернуть true
            ...
        }
    }
    

    Но такое поведение будет доступно только если вы реализуете промисы и только в async-функциях, само-собой.

    P.S. Советую взглянуть на библиотеку noty js
    Это то, что вам нужно, только с бОльшими возможностями.


  1. drtropin
    27.06.2017 17:49
    -3

    Спасибо за совет! Понятно, что есть новые технологии. Здесь решение несколько другой задачи — чуть-чуть подправить действующий проект согласно заданию. А код тем не менее если можно так сказать всё-таки «блокируется». Выводом в return. Для возобновления кода по выбору в диалоговом окне запускается заново, но то же самое диалоговое окно не генерируется, а возвращается «транзитом» выбор пользователя. В этом суть принципа.


    1. Akuma
      27.06.2017 19:51
      +1

      Смутил синтаксис if (...), посмотрел ваши исходники.
      Не делайте так :) Если кто-то будет потом поддерживать этот код, он повесится. Дважды, как и код.

      Поскольку заблокировать выполнение в JS не получится, вы решили «перекликивать» элемент. Это ужасное решение.

      1. Что если на клике висят и другие обработчики событий?
      2. Что если до if (jdDialog) есть каокй-то код?

      В обоих случаях код будет выполнен дважды.

      А если нужно вызвать диалог без клика? Ну и т.д.


      1. drtropin
        28.06.2017 09:49

        Совершенно верно, «перекликивать». Это ни в коем случае не универсальное решение на все случаи жизни. Для простых случаев, а таких задач думаю большинство, if confirm и alert вполне себе работает.
        Для более сложных алгоритмов естественно уже не подходит. Хотя и тут можно найти решение. Например по другому селектору инициировать клик. Здесь сознательно я иду на повторное выполнение кода, поэтому на этот факт придётся оглядываться.
        Ещё раз повторяю, найденное решение — для определённой ниши задач.


        1. Akuma
          28.06.2017 12:52

          Просто вы себе кучу проблем наживете в будущем. Дело конечно ваше, но почему бы не переделать, например так?

          let $link = $('a#some-link');
          
          $link.on('click', async function() {
              if (await showMessageBox('Текст сообщения')) {
                  console.log('Согласен');
              } else {
                  console.log('Не согласен');
              }
          });
          
          function showMessageBox(text) {
              let defer = new $.Deferred();
          
              let n = new Noty({
                  text: text,
                  timeout: false,
                  type: 'alert',
                  layout: 'center',
                  closeWith: [],
                  modal:true,
                  buttons:[
                      Noty.button('Да', 'btn-yes', function () {
                          defer.resolve(true);
                          n.close();
                      }),
          
                      Noty.button('Нет', 'btn-no', function () {
                          defer.resolve(false);
                          n.close();
                      }),
                  ],
              }).show();
          }
          


          Суть та же, только ничего не перевыполняется два раза. Если потребуется заменить бибилиотеку, сделать это проще некуда. Все что требуется — добавить async/await к обработчикам кликов. Ну и Babel, если нужны IE.

          Хотя, честно, я пользуюсь «чистыми» промисами — вполне устраивает.


  1. drtropin
    27.06.2017 18:09

    Noty.js не подходит. Вызов отличается от if(confirm()) {}. Данный плагин разработан именно для того, чтобы сделать замену стандартному confirm.


    1. chemaxa
      28.06.2017 09:37

      Я может чего не понимаю, но собственно где замена то стандартным диалоговым окнам у вас получилась?
      Если я пишу код

          if (confirm('Are you ready?')) {
            alert('Yeah!')
          }
      

      То вижу вполне себе стандартное окно, если же мне нужно писать другой код, то зачем плагин ваш? Почему не взять тот же noty.js?
      вот ссылка на мой пример, код взят из вашего репо http://embed.plnkr.co/aYjFDdH0LLGlTQK8hwTH/


      1. drtropin
        28.06.2017 09:39

        Вот замена для вашего примера:
        var project = 'Тестируем';
        if($(this).jdDialogs('confirm',1,['Are you ready?',project])) {
        $(this).jdDialogs('alert',0,['Yeah!',project]);
        }


        Или вот так.
        var project = 'Тестируем';
        if($(this).jdDialogs('confirm',1,['Are you ready?',project])) return;
        $(this).jdDialogs('alert',0,['Yeah!',project]);


        Не похоже?


      1. drtropin
        28.06.2017 10:07

        Noty.js не подошёл мне по синтаксису вызова (это указано в статье в постановке задачи), к тому же он более тяжеловесный. Речь в статье не о «добавил в свой код готовое решение и забыл», а об описании принципа. Плагин — это просто реализация принципа. Функционала данного плагина для замены диалоговых окон в моём проекте оказалось достаточно. Кстати если в confirm нужно обрабатывать cancel, достаточно разделить ответ 0 на Отмену и «транзитный вызов».


    1. justboris
      28.06.2017 11:36

      Что сложного переписать такой код


      if(confirm('Are you sure?')) {
         doSomething();
      }

      в такой?


      bootbox.confirm('Are you sure?', function(result) {
        if(result) {
          doSomething();
        }
      })

      (используется bootbox.js)


      А еще, как работает else блок в вашем варианте?


      if($(this).jdDialogs(....)) {
        console.log('ok')
      } else {
        console.log('canceled!');
      }

      Сколько раз слово canceled напишется в консоль?


      1. drtropin
        28.06.2017 12:54

        Добрый день! Да, спасибо! Если бы в своё время его нашёл, скорее всего он и был бы использован. Синтаксис вызова под задачу вполне подходящий. Но тогда придётся ставить в проект Bootstrap. Кроме того в предлагаемой мной реализации имеется полная свобода в оформлении диалогового окна (хоть полностью круглым его сделать), можно добавлять неограниченное кол-во кнопок и прочих элементов управления + шапка. Трёхкнопочное окно кстати в своём проекте использую.

        else блок в данном примере отработает 1 раз, т.к. при выборе кнопки «Отмена» повторный клик вообще не запускается. Изначально я разделял результат выбора «Отмена» и «транзитный вызов», но ни в одном из условий мне не понадобилось отмену обрабатывать.

        Чтобы «Отмену» обработать пусть она возвращает не 0, а 2
        .on('click','.jdCancel', function() {
        methods.jdSetAnswer(2,$(this));
        });


        И придётся жертвовать стандартным синтаксисом вызова.
        Например так:
        switch($(this).jdDialogs('confirm',0,[...])) {
        case 0: return;
        case 1:
        console.log('ok');
        break;
        case 2:
        console.log('canceled!');
        break;
        default:
        }


        Или так:
        var cnf = $(this).jdDialogs('confirm',0,[...]);
        if(!cnf) return;
        if(cnf == 1) {
        console.log('ok');
        } else {
        console.log('canceled!');
        }


        1. justboris
          28.06.2017 13:06

          Зачем вы предлагаете писать такое страшное мессиво из if-ов, вместо того чтобы сделать нормально?


          Bootbox я привел лишь как пример хорошего API, можете написать свое решение, которое будет работать точно так же.


          1. drtropin
            28.06.2017 14:51

            Да, к сожалению проблема лишнего кода имеет место — id диалогового окна, привязка к элементу. При увеличении количества кнопок лишний код. Тем не менее предложенный принцип работает. Предполагаю, что при развитии идеи вполне можно найти решения большинства шероховатостей. Ещё раз говорю, что принцип родился в процессе реализации конкретной задачи и может быть его можно применить не только для диалоговых окон. А Bootbox кто же спорит — безусловно отличный проект.


            1. justboris
              28.06.2017 16:01

              Главный принцип написания понятного кода — явное всегда лучше неявного. Вот код здорового человека


              function handleDeleteClick() {
                console.log('Before deleting record');
                showConfirmModal('Delete record?').then(function () {
                   sendDeleteRequest();
                }, function() {
                   console.log('Delete canceled')
                });
              }

              а это код курильщика, в который стреляет в ногу в неожиданных местах


              function handleDeleteClick() {
                console.log('Before deleting record');
                var result = $(this).jdDialogs('confirm',0,[...]);
                if(result) {
                  sendDeleteRequest();
                }
              }

              А теперь представьте, что вместо console.log там будут вызовы какой-то бизнес логики (сброс каких-нибудь состояний в UI) и я желаю вам счастливой отладки этого кода.


        1. ETCDema
          28.06.2017 13:52

          Ужас-ужас! Ну почему не сделать проще:


          ...dialog(msg, {
              'Ok':           function(){...},
              'И еще кнопка': function(){...},
              'И еще':        function(){...},
              'и еще!':       function(){...}
          },
          function() // А вот отмену лучше сделать отдельно и всегда добавлять кнопку
                     // + эту функцию можно повесить на другие контролы,
                     // например на закрытие окна или клик на placeholder  диалога...
          {
              ...
          });


          1. drtropin
            28.06.2017 14:59

            Предлагается механизм, с помощью которого получен результат. А вот например рекурсия функция ведь вызывает себя неограниченное кол-во раз и это не считается зазорным.


            1. ETCDema
              28.06.2017 18:11

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


              Вы привели отличный пример решения, которое у меня бы вызвало только один вопрос — вопрос о целесообразности работы в команде автора подобного решения. Уж извините за резкую оценку, но по-другому видимо мысль свою я донести до вас не смогу.


              1. drtropin
                29.06.2017 09:25

                Понятно. Большое спасибо за внимание! Отдельное спасибо за ценный совет никогда не защищать собственную идею.


  1. Reon
    27.06.2017 20:48
    +1

    Вы когда писали статью, не чувствовали праведный гнев надвигающийся на вас?


    1. drtropin
      28.06.2017 09:54
      -1

      Я давно уже читаю Хабру и вполне представляю как относятся к разработчикам, решившим поделиться найденным решением.


  1. ETCDema
    28.06.2017 12:17
    +1

    Имеющиеся разработки мне не подошли, т.к. хотелось по максимум сохранить синтаксис вызова…
    if(confirm('') ) {...}

    Выбранное решение сильно модифицирует код:


    1. Требует DOM элемент
    2. Самый ужас-ужас — многократное выполнение скрипта до confirm

    Логичней, проще и правильнее перевести код на callback:


    confirm = function(msg, onok, oncancel)
    {
        if (!ok && !oncancel) throw new Error('Пропустили что-то'); 
        ...тут использование либо готового решения, либо свое решение с вызовом onok|oncancel...
    };
    
    // Изменения в коде:
    
    // if(confirm('') ) {...} заменяется на
          confirm('', function(){...});
    
    // if(confirm('') ) {...} else {...} заменяется на
          confirm('', function(){...}, function(){...});

    Естественно, что код после if(confirm('') ) тоже нужно внести в функцию onok|oncancel или добавить еще одну:


    confirm = function(msg, onok, oncancel, always)
    {
        if (!ok && !oncancel) throw new Error('Пропустили что-то'); 
        ...тут использование либо готового решения, либо свое решение с вызовом onok|oncancel 
        ...и последующем вызовом always
    };
    // if(confirm('') ) {...code on ok...} ...code after confirm... заменяется на
          confirm('', function(){...code on ok...}, null, function(){...code after confirm...});


  1. drtropin
    29.06.2017 09:55

    Большое спасибо всем комментировавшим статью! Действительно много более надёжных вариантов решения.