September 24, 2021
Debate Site: Real Time, Diagrammatic Debate Interface, with Pfmindmap in React

Mind map debate

Debate Site is based on the idea of empowering good-faith users and facilitating productive debate. It uses a mind map diagram interface, which illustrates the struture of the discussion.

Ongoing development

The preliminary version of Debate Site is a starting point. It is a step on a path toward a more full-featured tool that helps users hash out complex topics.

By combining the diagram interface, a reactive site, and concepts from logic, it may be possible to reach a new level of fun and productive debate.

Users are encouraged to suggest, discuss and debate what features should be added, on the site.

Pfmindmap

Pfmindmap is a JavaScript module that makes it easy to implement a mind map interface. Debate Site demonstrates how pfmindmap can be used for real time discussion.

Real time

Real time content updating is easy, with Socket.io. Socket.io provides the “room” feature. With Debate Site, when a browser loads a debate, a socket connection is created between the client and the server, and the connection is subscribed to a socket.io room for that debate. When a new post is added to the debate, the server emits an event to the socket connections subscribed to that room. The client app listens for those socket events and adds new posts to the diagram.

Implementing pfmindmap in React

Install:

npm install pfmindmap

Import:

import pfmindmap from "pfmindmap";

Pfmindmap usage instructions:

  1. Instantiate the default class export of the module.
  2. Add data using the pfmindmap instance’s addDataItems method.
  3. Call the updateSimulationData method for the visualization to update.

When you instantiate the module, you pass the constructor a container element and 2 functions. One function is for creating diagram item HTML elements. The other function is for populating one of those elements with data.

Considerations for pfmindmap in React:

  • The container should render before setting up pfmindmap.
  • The container should not render again after setting up pfmindmap, if it can be avoided.

Code example

For Debate Site, we “raise the state” of the pfmindmap instance above the component that has the container element. This lets the higher component handle control logic in a flexible way, without having to worry about the container element rendering again.

Debate Site’s debate interface is composed of 4 components:

  • Debate.js
  • MindMap.js
  • PfmmItem.js
  • ReplyPopup.js

Debate.js

import React, { useState, useContext } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import ReplyPopup from './ReplyPopup.js';
import MindMap from './MindMap';
import { io } from "socket.io-client";

const useStyles = makeStyles((theme) => ({
    containerContainer: {
        height: '100%',
    }
}));

const Debate = props => {
    const classes = useStyles();
    const [pfmm, setPfmm] = useState(null);
    const [openDialogName, setOpenDialogName] = useState(null);
    const [replyTo, setReplyTo] = useState(null);
    const [socket, setSocket] = useState(null);

    const closeDialog = () => {
        setOpenDialogName(null);
    };
    
    function showReplyForm(replyToId) {
        setReplyTo(replyToId);
        setOpenDialogName('REPLY');
    }
    function handleClick(event) {
        if (event.target.closest('.itemReplyBtn')) {
            showReplyForm(event.target.closest('foreignObject').dataset.pfmmId, props.debate);
        }
    }
    function convertToPfmm(item, users) {
        let obj = {
            id: item._id,
            content: item.text,
            user: getUserObjectFromId(item.user, users)
        }
        if (item.isFirst) {
            obj = Object.assign(obj, {
                is_first: true,
                motion: props.debate.motion
            });
        } else {
            obj = Object.assign(obj, {
                reply_to_id: item.replyTo
            });
        }
        return obj;
    }
    function getUserObjectFromId(userId, users) {
        let filtered = users.filter(u => u._id === userId);
        if (filtered.length > 0) {
            return filtered[0];
        } else {
            return { _id: userId, name: userId };
        }
    }

    React.useEffect(() => {
        // useEffect runs again after pfmm gets set by child component (after debate is set)
        if (pfmm) {
            let convertedAr = props.debate.posts.map(x => convertToPfmm(x, props.users));
            pfmm.addDataItems(convertedAr);
            pfmm.updateSimulationData();

            // connect socket
            let theSocket = io(process.env.REACT_APP_API_URL, { path: process.env.REACT_APP_API_BASE_PATH + '/socket.io' });
            setSocket(theSocket);
            theSocket.emit('subscribe debate', props.debate._id);
            theSocket.on('new reply', d => {
                pfmm.addDataItems([convertToPfmm(d.post, [d.user])]);
                pfmm.updateSimulationData();
            });
            return () => theSocket.close();
        };
    }, [props.debate, pfmm]);
    return (
        <div className={classes.containerContainer} onClick={handleClick}>
            <MindMap setPFmm={setPfmm} debate={props.debate} />
            <ReplyPopup open={openDialogName === 'REPLY'} close={closeDialog} replyTo={replyTo} debateId={props.debate ? props.debate._id.toString() : ''} />
        </div>
    );
};

export default React.memo(Debate);

The top-level component, Debate.js, fills the width and height of wherever you put it. So, it can be used for a full screen interface, or, it can be placed in a smaller element within a page layout.

You can ignore convertToPfmm and getUserObjectFromId, which just transform data formats of some data, and are not relevant to this article.

In handleClick, event delegation is used for click handling within the diagram. That is because the itemCreator function (defined in MindMap.js) uses cloneNode(), which copies an element but not event listeners.

React.useEffect waits until pfmm is set and then loads data into it and tells it to update its display to reflect the new data. Then it sets up a socket connection and socket event listener, so new posts can be added to the diagram, in real time.

MindMap.js

import React from 'react';
import pfmindmap from "pfmindmap";
import PfmmItem from "./PfmmItem.js";
import { makeStyles } from '@material-ui/core/styles';

const useStyles = makeStyles((theme) => ({
    pfmmContainer: {
        height: '100%',
        background: 'grey',
        overflow: 'hidden'
    },
    containerContainer: {
        height: '100%',
    },
    hider: {
        display: "none"
    }
}));

const MindMap = props => {
    const classes = useStyles();
    let containerRef = React.createRef();

    React.useEffect(() => {
        // do this after debate is set
        if (props.debate) {
            let itemCreator = () => { return document.querySelector('.' + classes.hider + '>*').cloneNode(true); };
            let itemPopulator = (el, d) => {
                el.querySelector(".motionText").textContent = d["motion"];
                el.querySelector(".contentText").textContent = d["content"];
                el.querySelector(".userName").textContent = d.user.name;
                if (!d.is_first) {
                    el.querySelector(".content h3").remove();
                    el.querySelector(".motion").remove();
                }
                return el;
            };
            props.setPFmm(new pfmindmap(containerRef.current, itemCreator, itemPopulator, { 'item_width': 300 }));
        }
    }, [props.setPFmm, props.debate]);

    return (
        <div className={classes.containerContainer}>
            <div ref={containerRef} className={classes.pfmmContainer}></div>
            <div className={classes.hider}>
                <PfmmItem debate={props.debate} />
            </div>
        </div>
    );
};

export default React.memo(MindMap);

The MindMap component is, basically, the component with the container element for the diagram visualizer.

In React.useEffect, it waits until the debate object is set, so it won’t render again, then it sets up the pfmindmap instance.

The MindMap component is passed a setter, so we can set up the pfmindmap instance in this component, where it is easy to get the ref for the container.

MindMap is memoized, so the parent component can render multiple times, but the diagram container won’t.

PfmmItem is placed in a hidden div, because it is just used for making clones from it.

PfmmItem.js

import React, { useContext } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { UserContext } from '../../../context/user-context';
import { Box, Button, Card, CardContent, Typography } from '@material-ui/core/';

const useStyles = makeStyles((theme) => ({
    header: {
        margin: 0,
        fontWeight: 'bold'
    },
    motion: {
        marginBottom: "1em"
    },
    itemToClone: {
        background: "white",
        minHeight: "50px",
    },
    btnWrapper: {
        textAlign: 'center'
    }
}));

function PfmmItem(props) {
    const [user, dispatch] = useContext(UserContext);
    const classes = useStyles();

    function showReplyBtn() {
        if (!props.debate) {
            return false;
        }
        switch (props.debate.admittance) {
            case 'anyone':
                return true;
            case 'invite':
                if (user.logged_in_token && props.debate.invitees.includes(user.user_data.id)) {
                    return true;
                } else {
                    return false;
                }
        }
        return true;
    }

    return (
        <Card className={classes.itemToClone} elevation={3} variant="outlined">
            <CardContent>
                <Box sx={{ textAlign: 'right' }}>
                    <Typography className="userName" variant="body2" component="p" />
                </Box>
                <Box>
                    <div className={`${classes.motion} ${"motion"}`}>
                        <Typography className={classes.header} gutterBottom component="h3">
                            Motion
                        </Typography>
                    <Typography className="motionText" variant="body2" component="p" />
                    </div>
                    <div className="content">
                        <Typography className={classes.header} gutterBottom component="h3">
                            Opening Argument
                        </Typography>
                        <Typography className="contentText" variant="body2" component="p" style={{ whiteSpace: 'pre-line' }} />
                    </div>
                </Box>
                {
                    showReplyBtn() ?
                        (
                            <Box style={{ textAlign: 'center' }} mt={1}>
                                <Button className="itemReplyBtn" variant="contained" color="primary">
                                    Reply
                                </Button>
                            </Box>
                        ) : ''
                }
            </CardContent>
        </Card>
    );
}

export default PfmmItem;

The PfmmItem component is cloned for each diagram item.

showReplyBtn() determines whether the reply button should be shown, depending on properties of both the debate object and the user object.

PfmmItem includes elements used for different permutations of the diagram items. These can be conditionally deleted in the populator function. For example, the HTML elements used for the debate motion are only used for one diagram item, and not the others. [This pattern may be improved in a future version of pfmindmap, by combining the two functions passed to the pfmindmap constructor into one. That would allow the itemCreator function to clone or build different elements depending on the data.]

ReplyPopup.js

import React, { useState, useContext } from 'react';
import { UserContext } from '../../../context/user-context';
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, TextField } from '@material-ui/core';
import { addReply } from '../../../services/Debate';
import LogInOrRegister from '../Auth/LogInOrRegister';

export default function ReplyPopup(props) {
    const [replyText, setReplyText] = useState("");
    const [user, dispatch] = useContext(UserContext);
    const { logged_in_token, user_data } = user;

    const handleYes = () => {
        addReply(props.debateId, props.replyTo, replyText, logged_in_token);
        setReplyText('');
        props.close();
    };
    const changeReplyText = e => {
        setReplyText(e.target.value);
    };

    return (
        <Dialog open={props.open} onClose={props.close} aria-labelledby="form-dialog-title" fullWidth maxWidth="sm">
            {
                logged_in_token ?
                    (
                        <div>
                            <DialogTitle id="form-dialog-title">Reply</DialogTitle>
                            <DialogContent>
                                <TextField
                                    label="Reply text"
                                    value={replyText}
                                    variant="outlined"
                                    required
                                    multiline
                                    autoFocus
                                    fullWidth
                                    inputProps={{
                                        minLength: 10,
                                        maxLength: 1000
                                    }}
                                    onChange={changeReplyText}
                                />
                            </DialogContent>
                            <DialogActions>
                                <Button onClick={props.close} variant="contained" color="primary">
                                    Cancel
                                </Button>
                                <Button onClick={handleYes} variant="contained" color="primary">
                                    Send
                                </Button>
                            </DialogActions>
                        </div>
                    )
                    :
                    (
                        <LogInOrRegister actionString="to reply" />
                    )
                }
        </Dialog>
    );
}

The ReplyPopup component is just a React component for a reply form.

This ReplyPopup component has “reactive” React features, such as how the LoginOrRegister child component is conditionally rendered, depending on a property of the user state object.

Note that ReplyPopup and Debate are used like typical React components, changing based on state, but the other two components discussed here are different. PfmmItem should be thought of as HTML, because it gets cloned as an HTML element, before it is used in the diagram. It is built with React components, like Material-UI components, but the HTML clones that are used in the diagram are not living React components. And, MindMap is limitted in that it does not use local state, because changes to its local state would cause a render, requiring the diagram to be recreated.

Wrap up

So, that is one way to implement the mind map module in a React app.

Making tools that make debate and discussion more fun and constructive is a worthwhile goal. Pfmindmap was designed to be widely compatible and easy to use. Feel free to contact People’s Feelings with any questions or suggestions.

Please contribute to Debate Site by creating and participating in debates, such as on where to go with the site’s feature-set.

Notes

  • This article refers to pfmindmap 1.7.1

Links

Related Articles

Share this page