Practical Redux, Part 10: Managing Modals and Context Menus

This is a post in the Practical Redux series.


Techniques for managing Redux-driven UI components like modals and menus

Intro

Last time in Part 9, we upgraded Redux-ORM to 0.9 and discussed the migration, updated all other app dependencies, and used Yarn's "offline mirror" feature to cache package tarballs for offline repo installation. This time, we're going to set up modal dialogs that are driven by Redux, look at ways to get "return values" from dialogs while still following the principles of Redux, and implement a context menu system as well.

The code for this project is on Github at github.com/markerikson/project-minimek. The original WIP commits I made for this post can be seen in PR #12: Practical Redux Part 10 WIP, and the final "clean" commits can be seen in in PR #13: Practical Redux Part 10 Final.

I'll be linking to each "final" commit as I go through the post, as well as specific files in those commits. I won't paste every changed file in here or show every single changed line, to save space, but rather try to show the most relevant changes for each commit as appropriate.

Table of Contents

Adding Modal Dialogs

As I've mentioned, this series is not really meant to result in a fully-working meaningful application. It's primarily intended to give me reasons to show off specific useful React and Redux techniques. That means that there's a number of things that are going to be obvious over-engineering. Like, say, modal dialogs. This app doesn't need modal dialogs, but guess what: we're going to add modal dialogs to Project Mini-Mek anyway :)

The basic concepts of handling modals in React and driving them from Redux have been described many times elsewhere, in excellent detail. My links list has many articles on the topic, but here's a selected reading list:

We're going to put these concepts into action, and even expand on them in some ways.

Driving React Modals from Redux

Let's start by putting together the pieces needed to show a single modal dialog. We're going to need a few things:

  • A ModalManager component that takes a description of what modal component to show, and what props the modal should receive, plus a lookup table of available modal components, and renders the right modal component from that description
  • Actions and reducers that store and clear the description for the current modal
  • An actual modal component to show

Commit 21777d7: Add basic handling for a single modal dialog

features/modals/modalsReducer.js

import {
    MODAL_CLOSE,
    MODAL_OPEN
} from "./modalConstants";

import {createReducer} from "common/utils/reducerUtils";

const initialState = null;

export function openModal(state, payload) {
    const {modalType, modalProps} = payload;
    return {modalType, modalProps};
}

export function closeModal(state, payload) {
    return null;
}

export default createReducer(initialState,  {
    [MODAL_OPEN] : openModal,
    [MODAL_CLOSE] : closeModal
});

The reducer logic is trivial. We're simply going to store an object that contains the name of the modal type and the props it should receive, and either set the value or clear it out.

features/modals/TestModal.jsx

import React, {Component} from "react";
import {connect} from "react-redux";
import {
    Modal,
} from "semantic-ui-react";

import {closeModal} from "features/modals/modalActions";

const actions = {closeModal};

export class TestModal extends Component {
    render() {
        return (
            <Modal
                closeIcon="close"
                open={true}
                onClose={this.props.closeModal}
            >
                <Modal.Header>Modal #1</Modal.Header>
                <Modal.Content image>
                    <Modal.Description>
                        <p>This is a modal dialog.  Pretty neat, huh?</p>
                    </Modal.Description>
                </Modal.Content>
                <Modal.Actions>
                </Modal.Actions>
            </Modal>
        )
    }
}

export default connect(null, actions)(TestModal);

Semantic-UI-React conveniently has a Modal class already, which allows you to render headers, content, and action buttons. Since the focus of this article is on how to manipulate modals via React and Redux, and not specifically how to build them from scratch, we'll use the SUI-React Modal class rather than trying to build our own.

Note that we pass an open={true} prop to <Modal>. That's because the SUI-React Modal can be either opened or closed, and in our case, whenever we show a <TestModal>, we want the inner <Modal> to be displayed.

features/modals/ModalManager.jsx

import React, {Component} from "react";
import {connect} from "react-redux";

import TestModal from "./TestModal";

const modalComponentLookupTable = {
    TestModal
};

const mapState = (state) => ({currentModal : state.modals});

export class ModalManager extends Component {
    render() {
        const {currentModal} = this.props;

        let renderedModal;

        if(currentModal) {
            const {modalType, modalProps = {}} = currentModal;
            const ModalComponent = modalComponentLookupTable[modalType];

            renderedModal = <ModalComponent {...modalProps} />;
        }

        return <span>{renderedModal}</span>
    }
}

export default connect(mapState)(ModalManager);

Now we get to the heart of the basic modal setup. Let's take a step back first, though, and consider the theory behind this.

In a typical object-oriented GUI toolkit, you might do something like const myModal = new MyModal(); myModal.show(), and the modal class would be responsible for displaying itself somehow. This is also true with things like jQuery-based UI plugins as well. At that point, you have some "implicit state" in your application. Your app is "showing a modal", but there's no real way to track that this is happening beyond the fact that there's an instance of MyModal that's been created, or - if this is a web app - the fact that there's extra elements appended to the page. The "are we showing a modal?" state is implicitly true, but not actually being tracked anywhere.

With React (and even more so with Redux), you are encouraged to make as much of your state explicit as possible. That's why you frequently see apps that store a value like requesting : true, because you can now specifically base UI behavior on the fact that there's an AJAX request in progress (like showing a loading spinner). In our case, we want to explicitly track the fact that we're showing a modal, and even more than that, a description of what the current modal is.

Our <ModalManager> component will live near the top of the app's component tree. It reads the current modal description from Redux. If there's a description object, we need to actually show a modal.

We create a lookup table where the keys are some string identifier, and the values are modal components. In this case, we're just using the variable name as the key (via ES6 object literal shorthand syntax), so if modalType is "TestModal", that's the component class we'll retrieve. Once we have the right modal component class in a variable, we can use normal JSX syntax to render that component. We can also take whatever props object was included in the description, and pass that to the modal.

Here's what the result looks like:

A modal! With a backdrop! Isn't this exciting? :)

Code-wise, that's really all there is to this idea. Use some descriptive value to decide when you should show a modal, and if so, which one, then render it and pass in whatever props it's supposed to have. But, we can expand on this idea in a lot of useful ways.

Making Modals Stackable

Most of the time, you probably only need one modal open at once. But, what if you actually do need multiple modals open, stacked on top of each other? Fortunately, now that we've got the basic modal framework in place, this is pretty simple to add. All we really need to do have our reducer store an array of modal descriptions instead of just one, and have the ModalManager component render multiple modals in response.

While we're at it, we can update our TestModal component to actually make use of the new functionality.

Commit 3cd4fd2: Implement stackable modals

features/modals/modalReducer.js

-const initialState = null;
+const initialState = [];

export function openModal(state, payload) {
    const {modalType, modalProps} = payload;
-   return {modalType, modalProps};
+   // Always pushing a new modal onto the stack
+   return state.concat({modalType, modalProps});
}

export function closeModal(state, payload) {
-   return null;
+   // Always popping the last modal off the stack
+   const newState = state.slice();
+   newState.pop();
+   return newState;
}

features/modals/ModalManager.jsx

const mapState = (state) => ({currentModals : state.modals});

export class ModalManager extends Component {
    render() {
        const {currentModals} = this.props;

        const renderedModals = currentModals.map( (modalDescription, index) => {
            const {modalType, modalProps = {}} = modalDescription;
            const ModalComponent = modalComponentLookupTable[modalType];

            return <ModalComponent {...modalProps}  key={modalType + index}/>;
        });

        return <span>{renderedModals}</span>
    }
}

Our modalReducer cases switch to tracking a stack of modal descriptions in an array, and the ModalManager component changes to loop over that array and render an array of modal components.

features/modals/TestModal.jsx

import {connect} from "react-redux";
import {
    Modal,
+   Button,
} from "semantic-ui-react";

-import {closeModal} from "features/modals/modalActions";
+import {openModal, closeModal} from "features/modals/modalActions";

-const actions = {closeModal};
+const actions = {openModal, closeModal};

export class TestModal extends Component {
+   onNextModalClick = () => {
+       const {counter} = this.props;
+       this.props.openModal("TestModal", {counter : counter + 1});
+   }

    render() {
+       const {counter, closeModal} = this.props;

        return (
            <Modal
                closeIcon="close"
                open={true}
-               onClose={this.props.closeModal}
+               onClose={closeModal}
            >
-               <Modal.Header>Modal #1</Modal.Header>
+               <Modal.Header>Modal #{counter}</Modal.Header>
                <Modal.Content image>
                    <Modal.Description>
-                       <p>This is a modal dialog.  Pretty neat, huh?</p>
+                       <h4>
+                           Value from props:
+                       </h4>
+                       <div>
+                           counter = {counter}
+                       </div>
+                       <div>
+                           <Button onClick={this.onNextModalClick}>Add Another Modal</Button>
+                       </div>
                    </Modal.Description>
                </Modal.Content>
                <Modal.Actions>

Our TestModal gets changed in two ways. First, we'll take the props.counter value and display it as part of the header and the descriptive text. Then, we'll add a button and handle clicks on it by dispatching an action to show another modal on top of the current one. To illustrate that we're stacking them, we increase the counter value and pass it as a prop for the next modal in the stack.

Let's try it out! If we click the "Show Test Modal" button in the Tools menu, and then click "Add Another Modal" a few times, here's what we get (I temporarily added dimmer={false} to TestModal to keep the entire page from being blacked out):

Looks neat, and we've proven that we can show modals on the screen. Now, let's move on to building something useful.

Building a Reusable Color Picker Dialog

Military units usually have some kind of a logo or way to visually identify themselves. Right now, our fictional Battletech units only store a name and a faction affiliation. Let's add a field for a color as well.

If we're going to have a color field, we need some way to actually let the user pick the color. And obviously, if we're going to have a color picker, it should go in a modal dialog! :) (See previous comments about deliberate over-engineering.)

We'll need to build an input to show what the current color is and trigger the color picker dialog, as well as the dialog itself.

Creating the ColorPickerDialog

We've already got the ability to show a modal, so we only need to create a new modal component class that renders some kind of color picker component. We'll use the React-Color library, which provides a variety of color pickers in various styles.

Commit 9c6166e: Add React-Color library

The ColorPickerDialog itself will be very simple. We just need to track some kind of color value in its state, pass that to the color picker component when we render, and update the state when the user selects a different color. We'll also want to accept a callback prop that we can pass the new color to when the modal is closed successfully.

Commit a035b81: Add an initial ColorPickerDialog

common/components/ColorPickerDialog.jsx

import React, {Component} from "react";
import {connect} from "react-redux";
import {
    Modal,
    Button,
} from "semantic-ui-react";

import {SketchPicker} from "react-color";

import {closeModal} from "features/modals/modalActions";
import {noop} from "common/utils/clientUtils";

const actions = {closeModal};

export class ColorPickerDialog extends Component {
    constructor(props) {
        super();
        this.state = {
            color : props.color
        }
    }

    onSelectClicked = () => {
        this.props.colorSelected(this.state.color);

        this.props.closeModal();
    }

    onSelectedColorChanged = (colorEvent) => {
        this.setState({color : colorEvent.hex});
    }

    render() {
        const {closeModal} = this.props;

        return (
            <Modal
                closeIcon="close"
                open={true}
                onClose={closeModal}
                size="small"
            >
                <Modal.Header>Select Color</Modal.Header>
                <Modal.Content>
                    <SketchPicker
                        color={this.state.color}
                        onChangeComplete={this.onSelectedColorChanged}
                    />
                </Modal.Content>
                <Modal.Actions>
                    <Button positive onClick={this.onSelectClicked}>Select</Button>
                    <Button secondary onClick={closeModal}>Cancel</Button>
                </Modal.Actions>
            </Modal>
        )
    }
}

ColorPickerDialog.defaultProps = {
    color : "red",
    colorSelected : noop
};

export default connect(null, actions)(ColorPickerDialog);

Pretty straightforward. We copy the initial color value from props to state in the constructor, and have all future changes apply to the component state. (We also added the ColorPickerDialog to the lookup table in ModalManager as well.)

Here's what it looks like if we show it:

(Yes, the fact that the modal is so much bigger than the color picker component annoys me, but the Semantic-UI modal layouts don't seem to have much flexibility in size, and I don't feel like messing with this.)

Now that we have a dialog, we also need an input to show the current color:

Commit ee3c027: Add a simple ColorPickerButton

common/components/ColorPickerButton.jsx

import React from "react";

import {Button} from "semantic-ui-react";

const ColorPickerButton = ({value, onClick, disabled=false}) => {
    return (
        <Button
            type="button"
            style={{padding: "4px", margin: 0}}
            disabled={disabled}
            onClick={onClick}
        >
            <div
                style={{
                    width : 30,
                    height : 15,
                    backgroundColor : value
                }}
            />
        </Button>
    )
}

export default ColorPickerButton;

We take a standard SUI-React Button, and put a <div> in the middle to show the current color value.

We also need to actually add a color value to our store, and can use the ColorPickerButton to show that:

Commit 07ead5b: Add color field to unit info sample data and reducer

features/unitInfo/unitInfoReducer.js

const initialState = {
    name : "N/A",
    affiliation : "",
+   color : "blue"
};

features/unitInfo/UnitInfo/UnitInfo.jsx

import FormEditWrapper from "common/components/FormEditWrapper";
+import ColorPickerButton from "common/components/ColorPickerButton";

// skip ahead

    render() {
        const {unitInfo, updateUnitInfo} = this.props;
-       const {name, affiliation} = unitInfo;
+       const {name, affiliation, color} = unitInfo;

        return (
            <Segment attached="bottom">

// skip ahead
+                   <Form.Field name="color">
+                       <label>Color</label>
+                       <ColorPickerButton value={color} />
+                   </Form.Field>
                </Form>
            </Segment>

We should now see our ColorPickerButton onscreen:

And finally, we need to hook up the ColorPickerDialog so that it is shown when we click the ColorPickerButton. While we're at it, let's move the two color components into a separate subfolder so that we also have a place to put some Redux-related files:

Commit ede7d5c: Move ColorPicker components into a separate folder

Commit 8ad59aa: Connect UnitInfo color button to show the ColorPickerDialog

common/components/ColorPicker/colorPickerActions.js

import {
    openModal
} from "features/modals/modalActions";

export function showColorPicker(initialColor) {
    return openModal("ColorPickerDialog", {color : initialColor});
}

features/unitInfo/UnitInfo/UnitInfo.jsx

import {updateUnitInfo} from "../unitInfoActions";
+import {showColorPicker} from "common/components/ColorPicker/colorPickerActions";
import {getValueFromEvent} from "common/utils/clientUtils";

const actions = {
    updateUnitInfo,
+   showColorPicker,
};

// skip ahead

+   onColorClicked = () => {
+       this.props.showColorPicker(this.props.unitInfo.color);
+   }

    render() {
        const {unitInfo, updateUnitInfo} = this.props;

// skip ahead

                    <Form.Field name="color">
                        <label>Color</label>
-                       <ColorPickerButton value={color} />
+                       <ColorPickerButton
+                           value={color}
+                           onClick={this.onColorClicked}
+                       />
                    </Form.Field>

And with that, clicking on the color button in the Unit Info tab should now show our color picker with the current color that's in the store. Progress!

Using the Dialog Result Value

Unfortunately, now we have a problem. We can forward the current color value to the ColorPickerDialog as part of the "description" that we're storing in state, and use that as the initial color value in the dialog. However, we need some way to not only retrieve the final color value when the user clicks the "Select" button, but also actually use it to update the right field in the unit info reducer.

The obvious solution is to simply pass a callback function as another prop to the dialog, but that means we'd be storing the callback function in the Redux store. Per the Redux FAQ, putting non-serializable values in the store should be avoided. Now, at the technical level, doing this would work, but it would likely cause issues with time-travel debugging. It's also not the "right" way to do things with Redux. So, what can we do instead?

Earlier, I linked a previous post I'd written on handling return values from generic "picker" modals. The basic idea is to have the code that requested the modal also include a pre-built action object as a prop for the dialog. When the dialog succeeds, it dispatches that pre-built action, with its "return value" attached. It is a level of indirection, but it allows us to continue following the Redux principles.

Commit 2bfcd6e: Allow ColorPicker to dispatch pre-built actions after selection

common/components/ColorPicker/colorPickerActions.js

import _ from "lodash";

import { openModal } from "features/modals/modalActions";

export function showColorPicker(initialColor, onColorPickedAction) {
    // Define props that we want to "pass" to the ColorPicker dialog,
    // including the body of the action that should be dispatched when
    // the dialog is actually used to select a color.
    const colorPickerProps = {
        color : initialColor,
        onColorPicked : onColorPickedAction
    };
    return openModal("ColorPickerDialog", colorPickerProps);
}

export function colorSelected(color, actionToDispatch) {
    return (dispatch) => {
        if(actionToDispatch) {
            const newAction = _.cloneDeep(actionToDispatch);
            newAction.payload.color = color;

            dispatch(newAction);
        }
    }
}

We update the showColorPicker() action creator to take a second argument - the action that the caller wants dispatched upon success. We also add a thunk that the dialog can call to handle dispatching that arbitrary action. In the process, we also clone the entire action object just to be really sure that we're not accidentally mutating whatever payload is. (It's probably not necessary given how this is likely to be called, but it won't hurt to clone the action here.)

common/components/ColorPicker/ColorPickerDialog.jsx

import {closeModal} from "features/modals/modalActions";
-import {noop} from "common/utils/clientUtils";
+import {colorSelected} from "./colorPickerActions";

-const actions = {closeModal};
+const actions = {closeModal, colorSelected};

export class ColorPickerDialog extends Component {

// skip ahead
    onSelectClicked = () => {
-       this.props.colorSelected(this.state.color);
+       this.props.colorSelected(this.state.color, this.props.onColorPicked);
        this.props.closeModal();
    }

We update the click handler to call props.colorSelected() with both the new color value and the previously-supplied action object.

Now, all we need to do is have the UserInfo component pass along an appropriate action when we click the color button, and have a reducer case to handle that action.

Commit c0baa2f: Implement logic to set the unit color from a ColorPicker

features/unitInfo/unitInfoActions.js

-import {UNIT_INFO_UPDATE} from "./unitInfoConstants";
+import {
+   UNIT_INFO_UPDATE,
+   UNIT_INFO_SET_COLOR,
+} from "./unitInfoConstants";

export function updateUnitInfo(values) {
    return {
        type : UNIT_INFO_UPDATE,
        payload : values,
    };
}

+export function setUnitColor(color) {
+   return {
+       type : UNIT_INFO_SET_COLOR,
+       payload : {color}
+   };
+}

I've mostly skipped showing action creators to save space in the posts, but we'll show the setUnitColor() action creator just to emphasize that it's a simple action creator, nothing special at all.

features/unitInfo/unitInfoReducer.js

-function setUnitColor(state, payload) {
+   const {color} = payload;
+
+   return {
+       ...state,
+       color
+   };
+}

export default createReducer(initialState, {
    [DATA_LOADED] : dataLoaded,
    [UNIT_INFO_UPDATE] : updateUnitInfo,
+   [UNIT_INFO_SET_COLOR] : setUnitColor,
});

The new case reducer is also very simple - we extract the new color variable and apply that to our unit info state.

features/unitInfo/UnitInfo/UnitInfo.jsx

import {selectUnitInfo} from "../unitInfoSelectors";
-import {updateUnitInfo} from "../unitInfoActions";
+import {updateUnitInfo, setUnitColor} from "../unitInfoActions";
import {showColorPicker} from "common/components/ColorPicker/colorPickerActions";
import {getValueFromEvent} from "common/utils/clientUtils";

// skip ahead

    onColorClicked = () => {
-       this.props.showColorPicker(this.props.unitInfo.color);
+       const onColorPickedAction = setUnitColor();
+
+       this.props.showColorPicker(this.props.unitInfo.color, onColorPickedAction);
    }

And finally, we update the click handler for the color button. We create our "pre-built" action using the setUnitColor() action creator, but instead of dispatching it, we pass it to showColorPicker() so it can be included as a prop to the ColorPickerDialog.

Let's try it out. The color field in our sample data is "blue", while the default color prop to the ColorPickerDialog is "red". Let's try changing the color to something green, and confirm that it shows up in the color picker button in the unit info form:

Success! We were able to have the UnitInfo component request that the dialog be shown, pass along its own pre-built action, have the ColorPickerDialog dispatch, and update the unit info state with the result value from the dialog.

An Alternative Approach to Dialog Results

The Redux ecosystem includes libraries for almost every use case, and that includes modals. There's a variety of existing libraries for Redux-connected modals available. One particularly interesting library is redux-promising-modals. I haven't yet used it myself, but reading the docs, it appears to offer another valid solution to the question of handling return values from dialogs without breaking Redux principles.

redux-promising-modals offers actions and a reducer for tracking open modals, very similar to what we just implemented (but does not include any components). However, it also includes a middleware. Whenever you dispatch a PUSH_MODAL_WINDOW action, the middleware returns a promise, and tracks what modal that promise belongs to. You can then use that promise in the code that called dispatch(), and chain off of it. When that modal is closed, the middleware extracts results from the action, and resolves the original promise, thus supplying the "return values" from the dialog.

Here's an (untested) example of what using redux-promising-modals might look like:

import {pushModal} from "redux-promising-modals"

function showColorPickerForUnitInfo(initialColor) {
    return (dispatch) => {}
        dispatch(pushModal("ColorPickerDialog", {color : initialColor}))
            .then( (resultColor) => {
                dispatch(setUnitInfoColor(resultColor));
            });
    }
}

To me, this looks like an excellent use of the Redux middleware pipeline for intercepting actions, and is a nicely pre-built solution to the problem of tracking open modals and handling modal return values.

Designing a Context Menu System

Now that we've seen how to build a modal dialog system, we can apply the same principles to context menus. A context menu is really just another modal that's probably absolutely positioned on screen, doesn't have a dimmer overlay behind it, and contains just a menu instead of a titlebar, content area, and action buttons.

This might be a good time to go back and review a couple of the concepts around how modals in React can actually get shown on screen.

React Portals

We start our React applications by calling ReactDOM.render(<App />, rootElement). All of the nested HTML elements created by our React components are appended inside of that root element, in a single render tree. However, this can make showing modals a little bit awkward. especially if a very deeply nested child component wants to show a modal. That nested component can render a <Modal open={true} />, but now the HTML generated by the Modal component is going to be appended inside of the nested component. That means it probably won't show up correctly on top of the rest of the UI.

Now, sure, we can do some funky CSS stuff and make those elements pop out somehow, but there's a specific technique that's commonly used to make modals in React show up overlaid on the page contents. That technique is called a "portal". A "portal" is when a React component uses its lifecycle methods to start a second React render tree, usually appended to the page body. That way a nested component can render a <Modal>, but the modal content pops out on top of the page. React actually supports this with a semi-official method called ReactDOM.unstable_renderSubtreeIntoContainer. (Note that in React 16, this method will be replaced with a newer version called unstable_createPortal). The react-portal library wraps up that API to make it easier to use, and Semantic-UI-React's Modal component also uses that method.

Digging around the Semantic-UI-React docs and source, it looks like they do have that functionality broken out into a separate Portal component, so we could use that. But, for the sake of illustration, we'll use the react-portal library instead to help implement our context menu system.

Building the Context Menu System

Looking back at our description of what a "context menu" is, we said that it needs to be absolutely positioned on the screen. We can put together a generic component to help render something at an absolute position.

Commit e7c1cb3: Add an AbsolutePosition component

common/components/AbsolutePosition.jsx

import React from "react";
import PropTypes from "prop-types";

const AbsolutePosition = (props) => {
    const {children, nodeRef} = props;
    const style = {
        position: 'absolute',
        top: props.top,
        bottom : props.bottom,
        left: props.left,
        right : props.right,
        width: props.width,
    };

    return (
        <div style={style} className={props.className} ref={nodeRef}>
            {children}
        </div>
    );
}

AbsolutePosition.propTypes = {
    top: PropTypes.number,
    bottom : PropTypes.number,
    left: PropTypes.number,
    width: PropTypes.number,
    nodeRef : PropTypes.func,
};

export default AbsolutePosition;

All we really do here is set a div's style to position : "absolute", apply the provided positions, and insert the children inside the div. The only slightly unusual thing here is that we're taking a prop called nodeRef, and passing it down as a callback ref to the div. We'll see why that matters in a minute.

Now for the actual context menu behavior. First, we'll add the react-portal library to our app:

Commit 18de585: Add the React-Portal library

Then, we'll implement the core of our context menu functionality, very similar to how we build the ModalManager component and reducer logic earlier.

Commit 4fc3f4d: Implement core context menu handling logic

features/contextMenus/contextMenuReducer.js

import {createReducer} from "common/utils/reducerUtils";

import {
    CONTEXT_MENU_SHOW,
    CONTEXT_MENU_HIDE,
} from "./contextMenuConstants";

const contextMenuInitialState = {
    show : false,
    location : {
        x : null,
        y : null,
    },
    type : null,
    menuArgs : undefined,
}

function showContextMenu(state, payload) {
    return {
        ...state,
        show : true,
        ...payload
    };
}

function hideContextMenu(state, payload) {
    return {
        ...contextMenuInitialState
    }
};

export default createReducer(contextMenuInitialState, {
    [CONTEXT_MENU_SHOW] : showContextMenu,
    [CONTEXT_MENU_HIDE] : hideContextMenu
});

Our contextMenuReducer is fairly similar to the first iteration of the modal reducer. I probably could have done almost the same thing, where null represents no context menu and a valid object represents actually showing a menu, but wound up implementing this a bit differently in a couple ways. (Not entirely sure why, either, but I did :) )

We're going to track a show flag that indicates whether we're showing a menu, and type and menuArgs represent the same concepts as with our modals. We also need to track the location on screen where the menu should be positioned.

features/contextMenus/ContextMenu.jsx

import React, {Component} from "react";
import {connect} from "react-redux";

import AbsolutePosition from "common/components/AbsolutePosition";

import {hideContextMenu} from "./contextMenuActions";

const actions = {hideContextMenu};

export class ContextMenu extends Component {
    componentDidMount() {
        document.addEventListener('click', this.handleClickOutside, true);
    }

    componentWillUnmount() {
        document.removeEventListener('click', this.handleClickOutside, true);
    }

    handleClickOutside = (e) => {
        if (!this.node || !this.node.contains(e.target) ) {
            this.props.hideContextMenu();
        }
    }

    render() {
        const {location} = this.props;

        return (
            <AbsolutePosition
                left={location.x + 2}
                top={location.y}
                className="contextMenu"
                nodeRef={node => this.node = node}
            >
                {this.props.children}
            </AbsolutePosition>
        )
    }
}

export default connect(null, actions)(ContextMenu);

Next up we have a generic wrapper component for context menus. This component takes care of listening for clicks outside the menu and calling a close function, as well as using an <AbsolutePosition> component to put the menu in the right spot. Note that we offset the x coordinate by a couple pixels just to have the menu appear slightly offset from underneath the cursor. Finally, note that we use the nodeRef prop for AbsolutePosition. That's because we need to do some DOM checks to see if a click on the document is inside or outside the menu. Since the ContextMenu component doesn't render any actual HTML itself, it needs to have the AbsolutePosition component "forward a ref" on down. This is a useful technique, and Dan Abramov wrote an example of the "forwarded refs" pattern a while back.

features/contextMenus/ContextMenuManager.jsx

import React, {Component} from "react";
import {connect} from "react-redux";
import Portal from 'react-portal';

import ContextMenu from "./ContextMenu";

import {selectContextMenu} from "./contextMenuSelectors";

const menuTypes = {
};

export function contextMenuManagerMapState(state) {
    return {
        contextMenu : selectContextMenu(state)
    };
}

export class ContextMenuManager extends Component {
    render() {
        const {contextMenu} = this.props;
        const {show, location, type, menuArgs = {}} = contextMenu;

        let menu = null;

        if(show) {
            let MenuComponent = menuTypes[type];

            if(MenuComponent) {
                menu = (
                    <Portal isOpened={true}>
                        <ContextMenu location={location}>
                            <MenuComponent {...menuArgs} />
                        </ContextMenu>
                    </Portal>
                )
            }
        }

        return menu;
    }
}

export default connect(contextMenuManagerMapState)(ContextMenuManager);

Similar to our ModalManager component, the ContextMenuManager uses the description in Redux to look up the right menu component if appropriate, and renders it. In this case, we also surround the menu component with our <ContextMenu> component to put it in the right position and handle clicks outside of it, and surround that with a <Portal> to ensure that it floats over the UI.

Next up, we add the contextMenuReducer and the ContextMenuManager to our root reducer and the core application layout:

Commit f38f38b: Add the context menu reducer and component to the app

And we can now throw together a quick test menu component to verify that this is working (including adding it to the context menu lookup table):

Commit 958a2c3: Add an initial test context menu component and hook it up

features/contextMenus/TestContextMenu.jsx

import React, { Component } from 'react'
import { Menu } from 'semantic-ui-react'

export default class TestContextMenu extends Component {
    render() {
        return (
            <Menu vertical>
                <Menu.Item>
                    <Menu.Header>Menu Header: {this.props.text} </Menu.Header>
                    <Menu.Menu>
                        <Menu.Item>First Menu Item</Menu.Item>
                        <Menu.Item>Second Menu Item</Menu.Item>
                    </Menu.Menu>
                </Menu.Item>
            </Menu>
        )
    }
}

If we click our "Show Test Context Menu" button, here's what we should see:

Yay, a menu! That does nothing useful!

Adding a Context Menu to the Pilots List

The majority of Project Mini-Mek's functionality thus far has involved the "Pilots" tab, so we'll work with that. We've already added the ability to select a current pilot from the list, and delete a pilot, so let's add a context menu that offers that capability as well.

Commit 70208ea: Implement a context menu for pilots list items

features/pilots/PilotsList/PilotsListItemMenu.jsx

import React, { Component } from 'react'
import {connect} from "react-redux";
import { Menu } from 'semantic-ui-react'

import {selectPilot} from "../pilotsActions";
import {deleteEntity} from "features/entities/entityActions";
import {hideContextMenu} from "features/contextMenus/contextMenuActions";

const actions = {
    selectPilot,
    deleteEntity,
    hideContextMenu,
}

export class PilotsListItemMenu extends Component {
    onSelectClicked = () => {
        this.props.selectPilot(this.props.pilotId);
        this.props.hideContextMenu();
    }

    onDeleteClicked = () => {
        this.props.deleteEntity("Pilot", this.props.pilotId);
        this.props.hideContextMenu();
    }

    render() {
        return (
            <Menu vertical>
                <Menu.Item>
                    <Menu.Header>Pilot: {this.props.text} </Menu.Header>
                    <Menu.Menu>
                        <Menu.Item onClick={this.onSelectClicked}>Select Pilot</Menu.Item>
                        <Menu.Item onClick={this.onDeleteClicked}>Delete Pilot</Menu.Item>
                    </Menu.Menu>
                </Menu.Item>
            </Menu>
        )
    }
}

export default connect(null, actions)(PilotsListItemMenu);

Our pilots menu will take two props: the ID of a pilot, and some text that will be the name of the pilot that was clicked on. We show the pilot name in the header, and use props.pilotId to tell the action creators what pilot to select or delete.

features/pilots/PilotsList/PilotsListRow.jsx

import {getEntitiesSession} from "features/entities/entitySelectors";
+import {deleteEntity} from "features/entities/entityActions";
import {showContextMenu} from "features/contextMenus/contextMenuActions";

const actions = {
    deleteEntity,
+   showContextMenu,
};


-const PilotsListRow = ({pilot={}, onPilotClicked=_.noop, selected, deleteEntity}) => {
+const PilotsListRow = ({pilot={}, onPilotClicked=_.noop, selected, deleteEntity, showContextMenu}) => {
    const {
        id = null,
        name = "",


// skip ahead

+   const onRowRightClicked = (e) => {
+       e.preventDefault();
+       e.stopPropagation();
+
+       const {pageX, pageY} = e;
+       showContextMenu(pageX, pageY, "PilotsListItemMenu", {text: pilot.name, pilotId : id});
+   }

    return (
-       <Table.Row onClick={onRowClicked} active={selected}>
+       <Table.Row onClick={onRowClicked} onContextMenu={onRowRightClicked}  active={selected}>
            <Table.Cell>
                {name}
            </Table.Cell>

Over in the PilotsListRow component, we'll add a right-click handler on the entire row, and use the click coordinates in the page as the x/y location values for showing the menu. We also pass along the pilot name and ID values to the menu.

It's worth noting that the PilotsListRow component is a functional component that now has a lot going on inside of it. Frankly, it probably ought to be converted to a class component at this point, but I haven't yet gotten around to doing that. We may try to do that next time.

With that menu implemented, let's try it out:

Fixing Some Existing Bugs

In the process of adding this menu, I did actually find a couple bugs in the pilots list behavior (gasp!). We'd better go ahead and fix those now.

Both of these bugs occur when you take certain steps while there is a pilot selected. You can reproduce the first bug with these steps:

  • Select a pilot and make some edits
  • While still editing, select another pilot (either by left-clicking the row, or right-clicking the row and choosing "Select Pilot" from our new context menu).

You'll see that when a different pilot is selected, the work-in-progress edits are actually applied to the pilot, which isn't what we want. Selecting another pilot should canceL the edits entirely, not apply them.

The second bug can be reproduced by:

  • Start editing a pilot
  • Delete that pilot
  • Observe that the "Start Editing" button is still active, even though there's no longer a pilot visibly selected
  • Click "Start Editing"

You'll see our shiny new error overlay pop up with an error saying that Error: Pilot instance with id 6 not found.

Let's go ahead and fix both of those.

Commit 08af948: Fix a pair of bugs with pilot selection and editing

features/pilots/pilotsActions.js

export function selectPilot(pilotID) {
    return (dispatch, getState) => {
        const state = getState();
        const isEditing = selectIsEditingPilot(state);

        if(isEditing) {
-           dispatch(stopEditingPilot());
+           dispatch(cancelEditingPilot())
        }

        dispatch({
            type : PILOT_SELECT,
            payload : {currentPilot : pilotID},
        });
    }
}

The first bug is really silly and stupid. In the case where we select a pilot while already editing, I was dispatching stopEditingPilot(), which is the action creator that applies our edits. We actually need to dispatch cancelEditingPilot() instead.

features/pilots/pilotsReducer.js

export function stopEditingIfDeleted(state, payload) {
    const {itemType, itemID} = payload;
    const {isEditing, currentPilot} = state;

-   if(isEditing && itemType === "Pilot" && itemID === currentPilot) {
-       return stopEditingPilot(state, payload);
+   if(itemType === "Pilot" && itemID === currentPilot) {
+       return {
+           ...state,
+           isEditing : false,
+           currentPilot : null
+       }
    }

    return state;
}

The bug with the currentPilot field not clearing out is simply a logic error in our pilotsReducer. We were attempting to handle the case of a pilot being deleted while already being edited, by listening for the ENTITY_DELETE action and checking to see if the entity was of type "Pilot" and matched the selected pilot ID. But, we were only checking that pilot ID if we were editing, We really need to handle any time the currently selected pilot is deleted, and do so by clearing out both the currentPilot and isEditing fields.

Final Thoughts

Hopefully those examples have shown you how to implement your own modal handling using React and Redux. Those same techniques can be applied to drive other parts of your UI as well. For example, it's very straightforward to implement popup toast notifications using this approach.

Be sure to tune in next time, when we'll... ah... actually, I'm not sure what I'll tackle for the next part :) I have several more ideas for things I want to cover in this series, but I haven't yet decided what topic I'm going to cover next. If you've got any preferences for which topic I should write about next, please let me know in the comments or on Twitter!

Further Information


This is a post in the Practical Redux series. Other posts in this series:


Author Avatar

Mark Erikson

Collector of interesting links, answerer of questions