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

Я создаю приложение, которое должно отображать диалоговое окно подтверждения в некоторых ситуациях.

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

Мое сомнение приходит, когда этот диалог отправляется.

  • Как этот компонент может отправить правильное действие в соответствии с первым отправленным действием?
  • Если создатель действия обрабатывает эту логику?
  • Можем ли мы добавить действия внутри редуктора?

изменить:

чтобы сделать его понятным:

deleteThingA(id) => show dialog with Questions => deleteThingARemotely(id)

createThingB(id) => Show dialog with Questions => createThingBRemotely(id)

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

Ответ 1

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

Отправка действия для отображения модального

this.props.dispatch({
  type: 'SHOW_MODAL',
  modalType: 'DELETE_POST',
  modalProps: {
    postId: 42
  }
})

(Строки могут быть константами, конечно, Im использует встроенные строки для простоты.)

Написание редуктора для управления модальным состоянием

Затем убедитесь, что у вас есть редуктор, который просто принимает эти значения:

const initialState = {
  modalType: null,
  modalProps: {}
}

function modal(state = initialState, action) {
  switch (action.type) {
    case 'SHOW_MODAL':
      return {
        modalType: action.modalType,
        modalProps: action.modalProps
      }
    case 'HIDE_MODAL':
      return initialState
    default:
      return state
  }
}

/* .... */

const rootReducer = combineReducers({
  modal,
  /* other reducers */
})

Отлично! Теперь, когда вы отправляете какое-либо действие, state.modal будет обновляться, чтобы включить информацию о видимом в настоящее время модальном окне.

Написание корневого модального компонента

В корне вашей иерархии компонентов добавьте компонент <ModalRoot>, который подключен к хранилищу Redux. Он будет слушать state.modal и отображать соответствующий модальный компонент, пересылая реквизит из state.modal.modalProps.

// These are regular React components we will write soon
import DeletePostModal from './DeletePostModal'
import ConfirmLogoutModal from './ConfirmLogoutModal'

const MODAL_COMPONENTS = {
  'DELETE_POST': DeletePostModal,
  'CONFIRM_LOGOUT': ConfirmLogoutModal,
  /* other modals */
}

const ModalRoot = ({ modalType, modalProps }) => {
  if (!modalType) {
    return <span /> // after React v15 you can return null here
  }

  const SpecificModal = MODAL_COMPONENTS[modalType]
  return <SpecificModal {...modalProps} />
}

export default connect(
  state => state.modal
)(ModalRoot)

Что мы здесь сделали? ModalRoot считывает текущие modalType и modalProps из state.modal, к которым он подключен, и отображает соответствующий компонент, такой как DeletePostModal или ConfirmLogoutModal. Каждый модал является компонентом!

Написание специальных модальных компонентов

Здесь нет общих правил. Это всего лишь компоненты React, которые могут отправлять действия, читать что-то из состояния хранилища, и просто быть модалами.

Например, DeletePostModal может выглядеть так:

import { deletePost, hideModal } from '../actions'

const DeletePostModal = ({ post, dispatch }) => (
  <div>
    <p>Delete post {post.name}?</p>
    <button onClick={() => {
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    }}>
      Yes
    </button>
    <button onClick={() => dispatch(hideModal())}>
      Nope
    </button>
  </div>
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

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

Извлечение презентационного компонента

Было бы неудобно копировать-вставлять одну и ту же логику компоновки для каждого "конкретного" модального. Но у вас есть компоненты, верно? Таким образом, вы можете извлечь презентационный <Modal> компонент, который не знает, что делают определенные модалы, но обрабатывает, как они выглядят.

Тогда конкретные модалы, такие как DeletePostModal, могут использовать его для рендеринга:

import { deletePost, hideModal } from '../actions'
import Modal from './Modal'

const DeletePostModal = ({ post, dispatch }) => (
  <Modal
    dangerText={`Delete post ${post.name}?`}
    onDangerClick={() =>
      dispatch(deletePost(post.id)).then(() => {
        dispatch(hideModal())
      })
    })
  />
)

export default connect(
  (state, ownProps) => ({
    post: state.postsById[ownProps.postId]
  })
)(DeletePostModal)

Вам решать создать набор реквизитов, которые <Modal> может принять в вашем приложении, но я бы предположил, что у вас может быть несколько видов модалов (например, мода на модальность, подтверждение модальности и т.д.) и несколько стили для них.

Доступность и скрытие при нажатии на кнопку снаружи или клавишу Escape

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

Вместо того, чтобы давать вам советы по реализации этого, я предлагаю вам просто не выполнять его самостоятельно. Трудно получить право на доступность.

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

Вы даже можете обернуть react-modal в свой собственный <Modal>, который принимает реквизиты, специфичные для ваших приложений, и генерирует дочерние кнопки или другой контент. Все его компоненты!

Другие подходы

Существует несколько способов сделать это.

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

Фактически react-modal Я связался с ранее уже делает это внутренне, так что технически вам даже не нужно отображать его сверху. Мне все же приятно отделить модальный, который я хочу показать от компонента, отображающего его, но вы также можете использовать react-modal непосредственно из своих компонентов и пропустить большую часть того, что я написал выше.

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

Ответ 2

Обновление: введенные порталы React 16.0 через ссылку ReactDOM.createPortal

Обновление: следующие версии React (Fiber: вероятно, 16 или 17) будут включать метод для создания порталов: ReactDOM.unstable_createPortal() link


Используйте порталы

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

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

Что такое портал?

Портал позволяет вам визуализировать непосредственно внутри document.body элемент, который глубоко вложен в ваше дерево React.

Идея состоит в том, что, например, вы отображаете в теле следующее дерево React:

<div className="layout">
  <div className="outside-portal">
    <Portal>
      <div className="inside-portal">
        PortalContent
      </div>
    </Portal>
  </div>
</div>

И вы получите в качестве вывода:

<body>
  <div class="layout">
    <div class="outside-portal">
    </div>
  </div>
  <div class="inside-portal">
    PortalContent
  </div>
</body>

Узел inside-portal был переведен внутрь <body> вместо его обычного глубоко вложенного места.

Когда использовать портал

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

Зачем использовать портал

Больше никаких проблем с z-index: портал позволяет вам отображать в <body>. Если вы хотите отобразить всплывающее окно или раскрывающийся список, это действительно хорошая идея, если вы не хотите бороться с проблемами z-index. Добавляются элементы портала, выполняющие процедуру document.body в порядке монтирования, что означает, что, если вы не играете с z-index, поведение по умолчанию будет складывать порталы друг над другом в порядке монтирования. На практике это означает, что вы можете безопасно открывать всплывающее окно из другого всплывающего окна и быть уверенным, что второе всплывающее окно будет отображаться поверх первого, даже не думая о z-index.

На практике

Самое простое: использовать локальное состояние React: если вы думаете, что для простого всплывающего окна подтверждения удаления не стоит иметь шаблон Redux, то вы можете использовать портал, и это значительно упрощает ваш код. Для такого случая использования, где взаимодействие является очень локальным и на самом деле представляет собой довольно сложную деталь реализации, действительно ли вы заботитесь о горячей перезагрузке, перемещении во времени, регистрации действий и обо всех преимуществах, которые приносит вам Redux? Лично я не использую местное государство в этом случае. Код становится таким простым, как:

class DeleteButton extends React.Component {
  static propTypes = {
    onDelete: PropTypes.func.isRequired,
  };

  state = { confirmationPopup: false };

  open = () => {
    this.setState({ confirmationPopup: true });
  };

  close = () => {
    this.setState({ confirmationPopup: false });
  };

  render() {
    return (
      <div className="delete-button">
        <div onClick={() => this.open()}>Delete</div>
        {this.state.confirmationPopup && (
          <Portal>
            <DeleteConfirmationPopup
              onCancel={() => this.close()}
              onConfirm={() => {
                this.close();
                this.props.onDelete();
              }}
            />
          </Portal>
        )}
      </div>
    );
  }
}

Все просто: вы все еще можете использовать состояние Redux: если вы действительно хотите, вы все равно можете использовать connect чтобы выбрать, показывать или нет DeleteConfirmationPopup или нет. Поскольку портал остается глубоко вложенным в ваше дерево React, очень просто настроить поведение этого портала, потому что ваш родитель может передавать реквизиты на портал. Если вы не используете порталы, вам обычно приходится отображать всплывающие окна в верхней части дерева React по причинам z-index, и обычно приходится думать о вещах типа "как мне настроить общий DeleteConfirmationPopup, который я создал в соответствии с использованием" дело". И обычно вы найдете довольно хакерские решения этой проблемы, такие как отправка действия, которое содержит вложенные действия подтверждения/отмены, ключ пакета перевода, или, что еще хуже, функцию рендеринга (или что-то еще, что невозможно разобрать). Вам не нужно делать это с порталами, и вы можете просто передавать обычные реквизиты, так как DeleteConfirmationPopup - это просто потомок DeleteButton

Заключение

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

Обратите внимание, что реализации портала также могут помочь вам с другими полезными функциями, такими как:

  • доступность
  • Espace ярлыки, чтобы закрыть портал
  • Обрабатывать внешний клик (закрыть портал или нет)
  • Обрабатывать клик по ссылке (закрыть портал или нет)
  • React Context сделанный доступным в дереве портала

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

response-tether неизвестен большинству разработчиков React, но это один из самых полезных инструментов, которые вы можете там найти. Tether позволяет вам создавать порталы, но автоматически позиционирует портал относительно заданной цели. Это идеально подходит для всплывающих подсказок, выпадающих списков, горячих точек, справочных ящиков... Если у вас когда-либо возникали проблемы с absolute/relative и z-index, или ваш выпадающий список выходит за пределы области просмотра, Tether решит все это за вас.

Вы можете, например, легко внедрить "горячие точки", которые открываются во всплывающую подсказку:

Onboarding hotspot

Реальный производственный код здесь. Не может быть проще :)

<MenuHotspots.contacts>
  <ContactButton/>
</MenuHotspots.contacts>

Изменить: только что обнаруженный реагирующий шлюз, который позволяет рендерить порталы в выбранный вами узел (не обязательно тело)

Редактировать: кажется, что реакция-поппер может быть достойной альтернативой реакции-привязи. PopperJS - это библиотека, которая вычисляет только подходящую позицию для элемента, не касаясь DOM напрямую, позволяя пользователю выбирать, где и когда он хочет разместить узел DOM, в то время как Tether присоединяется непосредственно к телу.

Edit: там также response-slot-fill, которая интересна и может помочь решить подобные проблемы, позволяя визуализировать элемент в зарезервированный слот элемента, который вы помещаете в любое место в своем дереве

Ответ 3

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

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

Я предлагаю решение, адресованное для устранения этой проблемы. Более подробное определение проблемы, src и примеры можно найти здесь: https://github.com/fckt/react-layer-stack#rationale

обоснование

react/react-dom приходит с двумя основными предположениями/идеями:

  • каждый пользовательский интерфейс, естественно, иерархический. Вот почему у нас есть идея components которые обертывают друг друга
  • react-dom монтирует (физически) дочерний компонент к своему родительскому узлу DOM по умолчанию

Проблема в том, что иногда второе свойство не то, что вы хотите в вашем случае. Иногда вы хотите смонтировать ваш компонент в другой физический узел DOM и одновременно поддерживать логическое соединение между родителем и потомком.

Каноническим примером является Tooltip-подобный компонент: в какой-то момент процесса разработки вы можете обнаружить, что вам нужно добавить некоторое описание для вашего UI element: он будет отображаться в фиксированном слое и должен знать его координаты (которые являются координатами этого UI element или мышью). Координаты) и в то же время ему нужна информация о том, должна ли она быть показана прямо сейчас или нет, ее содержание и некоторый контекст из родительских компонентов. Этот пример показывает, что иногда логическая иерархия не совпадает с физической иерархией DOM.

Взгляните на https://github.com/fckt/react-layer-stack/blob/master/README.md#real-world-usage-example, чтобы увидеть конкретный пример, который является ответом на ваш вопрос:

import { Layer, LayerContext } from 'react-layer-stack'
// ... for each 'object' in array of 'objects'
  const modalId = 'DeleteObjectConfirmation' + objects[rowIndex].id
  return (
    <Cell {...props}>
        // the layer definition. The content will show up in the LayerStackMountPoint when 'show(modalId)' be fired in LayerContext
        <Layer use={[objects[rowIndex], rowIndex]} id={modalId}> {({
            hideMe, // alias for 'hide(modalId)'
            index } // useful to know to set zIndex, for example
            , e) => // access to the arguments (click event data in this example)
          <Modal onClick={ hideMe } zIndex={(index + 1) * 1000}>
            <ConfirmationDialog
              title={ 'Delete' }
              message={ "You're about to delete to " + '"' + objects[rowIndex].name + '"' }
              confirmButton={ <Button type="primary">DELETE</Button> }
              onConfirm={ this.handleDeleteObject.bind(this, objects[rowIndex].name, hideMe) } // hide after confirmation
              close={ hideMe } />
          </Modal> }
        </Layer>

        // this is the toggle for Layer with 'id === modalId' can be defined everywhere in the components tree
        <LayerContext id={ modalId }> {({showMe}) => // showMe is alias for 'show(modalId)'
          <div style={styles.iconOverlay} onClick={ (e) => showMe(e) }> // additional arguments can be passed (like event)
            <Icon type="trash" />
          </div> }
        </LayerContext>
    </Cell>)
// ...

Ответ 4

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

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

import React from 'react';
import PropTypes from 'prop-types';
import Portal from 'react-portal';

class ModalContainer extends React.Component {
  state = {
    isOpen: false,
  };

  openModal = () => {
    this.setState(() => ({ isOpen: true }));
  }

  closeModal = () => {
    this.setState(() => ({ isOpen: false }));
  }

  renderModal() {
    return (
      this.props.renderModal({
        isOpen: this.state.isOpen,
        closeModal: this.closeModal,
      })
    );
  }

  renderTrigger() {
     return (
       this.props.renderTrigger({
         openModal: this.openModal
       })
     )
  }

  render() {
    return (
      <React.Fragment>
        <Portal>
          {this.renderModal()}
        </Portal>
        {this.renderTrigger()}
      </React.Fragment>
    );
  }
}

ModalContainer.propTypes = {
  renderModal: PropTypes.func.isRequired,
  renderTrigger: PropTypes.func.isRequired,
};

export default ModalContainer;

А вот простой пример использования...

import React from 'react';
import Modal from 'react-modal';
import Fade from 'components/Animations/Fade';
import ModalContainer from 'components/ModalContainer';

const SimpleModal = ({ isOpen, closeModal }) => (
  <Fade visible={isOpen}> // example use case with animation components
    <Modal>
      <Button onClick={closeModal}>
        close modal
      </Button>
    </Modal>
  </Fade>
);

const SimpleModalButton = ({ openModal }) => (
  <button onClick={openModal}>
    open modal
  </button>
);

const SimpleButtonWithModal = () => (
   <ModalContainer
     renderModal={props => <SimpleModal {...props} />}
     renderTrigger={props => <SimpleModalButton {...props} />}
   />
);

export default SimpleButtonWithModal;

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

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

import React from 'react'
import partialRight from 'lodash/partialRight';
import ModalContainer from 'components/ModalContainer';

class ErrorModalContainer extends React.Component {
  state = { message: '' }

  onError = (message, callback) => {
    this.setState(
      () => ({ message }),
      () => callback && callback()
    );
  }

  renderModal = (props) => (
    this.props.renderModal({
       ...props,
       message: this.state.message,
    })
  )

  renderTrigger = (props) => (
    this.props.renderTrigger({
      openModal: partialRight(this.onError, props.openModal)
    })
  )

  render() {
    return (
      <ModalContainer
        renderModal={this.renderModal}
        renderTrigger={this.renderTrigger}
      />
    )
  }
}

ErrorModalContainer.propTypes = (
  ModalContainer.propTypes
);

export default ErrorModalContainer;

Ответ 5

Оберните модал в подключенный контейнер и выполните здесь асинхронную операцию. Таким образом, вы можете связаться как с диспетчером для запуска действий, так и с onClose. (Чтобы достичь dispatch из реквизита, не mapDispatchToProps функцию mapDispatchToProps для connect.

class ModalConteiner extends React.Component {
  handleDelete = () => {
    const { dispatch, onClose } = this.props;
    dispatch({type: 'DELETE_POST'});

    someAsyncOperation().then(() => {
      dispatch({type: 'DELETE_POST_SUCCESS'});
      onClose();
    })
  }

  render() {
    const { onClose } = this.props;
    return <Modal onClose={onClose} onSubmit={this.handleDelete} />
  }
}

export default connect(/* no map dispatch to props here! */)(ModalContainer);

Приложение, в котором отображается модал и устанавливается его состояние видимости:

class App extends React.Component {
  state = {
    isModalOpen: false
  }

  handleModalClose = () => this.setState({ isModalOpen: false });

  ...

  render(){
    return (
      ...
      <ModalContainer onClose={this.handleModalClose} />  
      ...
    )
  }

}