Что такое диалоговое окно?

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

Нас интересует
ожидая от него ответа
Другими словами мы открываем модальное окно, чтобы получить обратную связь и что-то после этого выполнить. Ничего не напоминает? И я так подумал.

Представим себе ситуацию, у нас есть приложение для управления пользователями.
Сценарий следующий.

На главной странице пользователь может открыть модальное окно списка других пользователей.
Из списка можно открыть модальное окно с информацией о пользователе, так же в этом окне есть форма для отправки письма.

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

Читать достаточно сложно представим эту задачу в виде диаграммы последовательности.

image

Теперь все гораздо проще.

С точки зрения кода, открытие модального окна это синхронное действие, мы его можем открыть, внутри себя оно может закрыться, но что если при изменении данных в модальном окне, при его закрытии нужно получить оттуда данные?

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

Попахивает асинхронщиной…

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

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

image

Теперь все становится просто, когда пользователь открывает модальное окно, мы дожидаемся пока он не закончит свои дела, после этого вызывается resolve у промиса. Звучит просто, приступим к реализации.

Мой основной фреймворк это реакт, поэтому сразу будем делать на его основе. Для того, чтобы можно было открывать модальные окна из любой части приложения, будем использовать Context API.

Первым делом нам необходимо создать контекст и место, где он будет храниться.

// ./Provider.js
export const DialogContext = React.createContext();

export const Provider = ({ children, node, Layout, config }) => {
  const [instances, setInstances] = useState([]);
  const [events, setEvents] = useState([]);

  const context = {
    instances,
    setInstances,
    config,
    events,
    setEvents
  };

  const Component = instances.map(instance => (
    <Layout
      key={instance.instanceName}
      component={config[instance.instanceName]}
      {...instance}
    />
  ));

 const context = {
     instances
     setInstances
  };

  // При изменении state не обновляем дочерние компоненты
  const child = useMemo(() => React.Children.only(children), [children]);

  return (
    <DialogContext.Provider value={context}>
      <>
        {child}
        {createPortal(Component, node)}
      </>
    </DialogContext.Provider>
  );
};

Тут все просто, мы используем первый useState для создания массива открытых модальных окон. Нечто похожее на стек.

Второй это useState необходим для того, чтобы складывать туда ссылки на resolve и reject у promise. Это мы увидим ниже.

Перенаправляем рендер через портал, чтобы не приходилось бороться в случае чего с z-index.

Layout это компонент, который будет базовым компонентом для всех модальных окон.

Параметр config это просто объект, где ключ это идентификатор модального окна, а значение это компонент модального окна.

// Пример config.js
export const exampleInstanceName = 'modal/example';

export default {
  [exampleInstanceName]: React.lazy(() => import('./Example')),
};

Теперь напишем реализацию метода, который будет открывать модальные окна.

Это будет хук:


export const useDialog = () => {
  const { setEvents, setInstances, config } = useContext(DialogContext);

  const open = instance =>
    new Promise((resolve, reject) => {
      if (instance.instanceName in config) {
        setInstances(prevInstances => [...prevInstances, instance]);
        setEvents(prevEvents => [...prevEvents, { resolve, reject }]);
      } else {
        throw new Error(`${instance['instanceName']} don't exist in modal config`);
      }
    });

  return { open };
};

Хук возвращает функцию open, которую мы можем использовать, чтобы вызвать модальное окно.

import { exampleInstanceName } from './config';
import { useDialog } from './useDialog';

const FillFormButton = () => {
   const { open } = useDialog();
  const fillForm = () => open(exampleInstanceName)

  return <button onClick={fillForm}>fill form from modal</button>
}

В данном варианте, мы никогда не дождемся закрытия модального окна, нам необходимо добавить методы для завершения промиса:

// ./Provider.js
export const DialogContext = React.createContext();

export const Provider = ({ children, node, Layout, config }) => {
  const [instances, setInstances] = useState([]);
  const [events, setEvents] = useState([]);

  const close = useCallback(() => {
    const { resolve } = events[events.length - 1];
    const resolveParams = { action: actions.close };

    setInstances(prevInstances => prevInstances.filter((_, index) => index !== prevInstances.length - 1));
    setEvents(prevEvents => prevEvents.filter((_, index) => index !== prevEvents.length - 1));

    resolve(resolveParams);
  }, [events]);

  const cancel = useCallback((values): void => {
    const { resolve } = events[events.length - 1];
    const resolveParams = { action: actions.cancel, values };

    setInstances(prevInstances => prevInstances.filter((_el, index) => index !== prevInstances.length - 1));
    setEvents(prevEvents => prevEvents.filter((_el, index) => index !== prevEvents.length - 1));

    resolve(resolveParams);
  },  [events]);

  const success = useCallback((values) => {
      const { resolve } = events[events.length - 1];
      const resolveParams = { action: actions.success, values };

      setInstances(prevInstances => prevInstances.filter((_el, index) => index !== prevInstances.length - 1));
      setEvents(prevEvents => prevEvents.filter((_el, index) => index !== prevEvents.length - 1));

      resolve(resolveParams);
    }, [events]);

  const context = {
    instances,
    setInstances,
    config,
    events,
    setEvents
  };

  const Component = instances.map(instance => (
    <Layout
      key={instance.instanceName}
      component={config[instance.instanceName]}
      cancel={cancel}
      success={success}
      close={close}
      {...instance}
    />
  ));

 const context = {
     instances
     setInstances
  };

  // При изменении state не обновляем дочерние компоненты
  const child = useMemo(() => React.Children.only(children), [children]);

  return (
    <DialogContext.Provider value={context}>
      <>
        {child}
        {createPortal(Component, node)}
      </>
    </DialogContext.Provider>
  );
};

Теперь когда в компоненте Layout или если он передает эти методы в компонент модального окна, будут вызваны методы success, cancel или close у нас выполнится resolve у необходимого promise. Тут добавляется такое понятие как action, это строка которая отвечает в каком статусе был завершен диалог. Это нам может пригодиться, когда мы будем выполнять какое либо действие после выполнения модального окна:

const { useState } from 'rect';
import { exampleInstanceName } from './config';
import { useDialog } from './useDialog';

const FillFormButton = () => {
  const [disabled, setDisabled] = useState(false); 
  const { open } = useDialog();
  const fillForm = () => open(exampleInstanceName)
    .then(({ action }) => {
      if (action === 'success') setDisabled(true);
    });

  return <button onClick={fillForm} disabled={disabled}>fill form from modal</button>
}

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

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


  1. ua9msn
    09.10.2019 21:47
    +2

    Я делал так что бы вызов модального диалога не отличался от вызова нативного алерта.

    onSomethingClicked = async () => {
            const ok = await AsyncConfirm({
                text: Localizer.get('app.something.confirm'),
                okButtonText:Localizer.get('dialog.yes'),
                noButtonText: Localizer.get('dialog.no'),
            });
    
            if(ok){
                TransportController.get('/api/user/something')
                    .then(data => {
                       doAnything(data)
                    })
                    .catch(error => {
                        feedback.error(STATUS.FAILURE, error);
                    });
            }
            
        };
    
    

    Если б я только мог подумать что эта тема стоит отдельной статьи…


    1. merrick_krg Автор
      09.10.2019 22:08

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


      1. ua9msn
        09.10.2019 23:02
        +1

        Да я не спорю с хуками, с редаксом, с провайдерами, с контекстами…
        Может помните такой PHP фреймворк — Zend? Очень много народу его использовало, все вроде хорошо. Однако, мне лично(подчеркнуто), он сразу казался overengeneered. Всё правильно, всё по инструкции, не докопаться. Но и сходу не разобраться. Класс на наследовании инкапсуляцией полиморфизм погоняет. Ваш подход, на мой личный(подчеркнуто) взгляд — overengeenered.


    1. psFitz
      10.10.2019 10:23

      Три плюса этому господину, делал подобное, когда надо было получить да/нет ответ с модалки, даже не подозревал, что можно статью написать и словить плюсов, или у меня синдром самозванца или у автора какой-то другой)


      1. merrick_krg Автор
        10.10.2019 10:33

        Ну это ведь хабр. Решили проблему, поделитесь. Возможно кому нибудь пригодиться.


  1. Golovanoff
    10.10.2019 05:15

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


    1. merrick_krg Автор
      10.10.2019 10:32

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


    1. ua9msn
      10.10.2019 11:08

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


      1. psFitz
        10.10.2019 11:23

        нет ничего плохого в грамотно реализованной модалке.
        Что-бы по хоткеем закрывалась, открывалась по url. не налаживалась на другую модалку.


  1. bodqhrohro
    10.10.2019 11:53
    -2

    Спасибо за статью, в очередной раз напомнила, что в современном React я разбираюсь, как свинья (то есть я!) в апельсинах. Контексты, порталы, useState какой-то — чего несут? o_O


    Подход напоминает Java'овский, где в тонне фабрик, интерфейсов, паттернов и прочих абстракций тонет действительно полезный код. А на TypeScript бы и того печальнее выглядело, пожалуй. Уж не бывшие Java'исты ли и испортили вебдев?


    1. JustDont
      10.10.2019 12:52

      Это вы голимую функциональщину-то пытаетесь на явистов переложить? Очень смешно.


      1. merrick_krg Автор
        10.10.2019 13:07

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


        1. JustDont
          10.10.2019 13:21

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


  1. reforms
    11.10.2019 10:41

    Может я избалован vue, но мне приведенные примеры кажутся непосильно сложными, возможно из-за субъективного восприятия react. Приведу пример, как мы работает с модальными диалогами в vue и ts:

    // Класс, отвечающий за логику сохранения документа
    class SaveDocumentEngine {
    
        async saveDocument(doc: Document): Promise<void> {
            const docCount = 1; // константа введена для наглядности
            const userAnswer = await new SaveDocsConfirmDialog().show(docCount);
            // Если пользователь передумал
            if (userAnswer !== UserChoice.SAVE) {
                return;
            }
            // логика сохранения и проверки документа
        }
    }
    

    Явное создание диалога, вызов с параметрами и обработка результата — ничего лишнего. А вся специфика спрятана в самом диалоге.
    /** Диалог подтверждения сохранения документа/документов */
    @Component({
        // language=Vue
        template: `
    <dialog-form title="Предупреждение" :width="500">
        <template slot="content">
            Вы действительно хотите сохранить {{data > 1 ? "документы" : "документ"}}?
        </template>
        <template slot="footer">
            <button class="btn btn-primary" @click="onSave">Сохранить</button>
            <button class="btn" @click="onCancel">Отмена</button>
        </template>
    </dialog-form>
    `
    })
    export class SaveDocsConfirmDialog extends CustomDialog<DocCount, UserChoice> {
        /** Желание сохранить */
        private onSave() {
            this.close(UserChoice.SAVE);
        }
        /** Желание отменить */
        private onCancel() {
            this.close(UserChoice.CANCEL);
        }
    }
    
    // Тип - Количество документов
    type DocCount = number;
    
    // Тип - Выбор пользователя
    export enum UserChoice {
        SAVE, CANCEL
    }