1
\$\begingroup\$

I'm building a graph-based interface to explore Wikipedia, but I'm not really familiar with TypeScript/React (especially state management), so I really feel like I'm just Frankensteining things together, which makes me feel pretty uncomfortable. I've found many things in my code so far that have made me facepalm. I'd appreciate any feedback on state management, modularity, style, implementation, or anything else!

If anyone wants to take a look at the entire source-code: https://github.com/lee-janice/wikigraph

Otherwise, here's the core bit:

App.tsx

import "./styles/App.css";
import WikiGraph from "./components/wikigraph";
import { useEffect, useState } from "react";

const NEO4J_DB = String(process.env.REACT_APP_NEO4J_DB);
const NEO4J_URI = String(process.env.REACT_APP_NEO4J_URI);
const NEO4J_USER = String(process.env.REACT_APP_NEO4J_USER);
const NEO4J_PASSWORD = String(process.env.REACT_APP_NEO4J_PASSWORD);

function App() {
    // set initial theme and keep track of dark mode state
    const [darkMode, setDarkMode] = useState(window.matchMedia("(prefers-color-scheme: dark)").matches);
    // handle change in dark mode toggle
    useEffect(() => {
        if (darkMode) {
            document.body.classList.add("dark");
            document.body.classList.remove("light");
        } else {
            document.body.classList.add("light");
            document.body.classList.remove("dark");
        }
    }, [darkMode]);

    return (
        <>
            <header>
                <h1>
                    <strong>WikiGraph</strong>
                </h1>
                <p className="subtitle">A graph-based approach to exploring the depths of Wikipedia</p>
            </header>
            <div className="App">
                {/* graph visualization */}
                <WikiGraph
                    containerId={"vis"}
                    serverDatabase={NEO4J_DB}
                    serverURI={NEO4J_URI}
                    serverUser={NEO4J_USER}
                    serverPassword={NEO4J_PASSWORD}
                    darkMode={darkMode}
                />
                {/* light/dark mode toggle */}
                <label id="theme-toggle">
                    <input type="checkbox" checked={darkMode} onChange={() => setDarkMode(!darkMode)} /> Dark mode
                </label>
            </div>
        </>
    );
}

export default App;

wikigraph.tsx

import { useEffect, useRef, useState } from "react";
import NeoVis, { NeoVisEvents } from "neovis.js/dist/neovis.js";
import ContextMenu, { ContextMenuState, ContextMenuType } from "./contextMenu";
import NavBar, { NavTab } from "./sidebar/navbar";
import UserManual from "./sidebar/userManual";
import About from "./sidebar/about";
import WikipediaSummaries, { WikiSummary } from "./sidebar/wikipediaSummaries";
import styled from "styled-components";
import { createConfig } from "../util/neo4jConfig";

const StyledCanvas = styled.div`
    height: ${(props) => (props.theme.expanded ? "100%;" : "80%;")}
    width: ${(props) => (props.theme.expanded ? "100%;" : "60%;")}
    top: ${(props) => (props.theme.expanded ? "0px;" : "inherit;")}
    left: ${(props) => (props.theme.expanded ? "0px;" : "inherit;")}
    z-index: ${(props) => (props.theme.expanded ? "100000;" : "100;")}
    position: fixed;

    @media (max-width: 1100px) {
        height: ${(props) => (props.theme.expanded ? "100%;" : "55%;")}
        width: ${(props) => (props.theme.expanded ? "100%;" : "90%;")}
    }
`;

StyledCanvas.defaultProps = {
    theme: {
        expanded: false,
    },
};

/* https://www.w3schools.com/howto/howto_css_fixed_sidebar.asp */
const StyledSidebar = styled.div`
    height: 100%;
    width: 33%;
    padding-top: 20px;
    top: 0;
    right: 0;
    position: fixed; /* stay in place on scroll */
    z-index: 100;
    overflow-x: hidden; /* disable horizontal scroll */
    border-left: 1px solid var(--borderColor);
    background-color: var(--primaryBackgroundColor);

    @media (max-width: 1100px) {
        height: 100%;
        width: 100%;
        top: 80%;
        display: block;
        position: absolute;
        z-index: 10000;
        border-left: none;
        border-top: 1px solid var(--borderColor);
    }
`;

// TODO: figure out how to import this from vis.js
export type IdType = string | number;

interface Props {
    containerId: string;
    serverDatabase: string;
    serverURI: string;
    serverUser: string;
    serverPassword: string;
    darkMode: boolean;
}

const WikiGraph: React.FC<Props> = ({
    containerId,
    serverDatabase,
    serverURI,
    serverUser,
    serverPassword,
    darkMode,
}) => {
    // keep vis object in state
    const [vis, setVis] = useState<NeoVis | null>(null);
    const [visIsExpanded, setVisIsExpanded] = useState(false);

    // keep track of selected nodes and labels
    // TODO: combine into one object
    const [selection, setSelection] = useState<IdType[]>([]);
    const [selectionLabels, setSelectionLabels] = useState([""]);

    // keep track of summaries
    // TODO: combine into one object
    const [summaries, setSummaries] = useState<WikiSummary[]>([]);
    const [currentSummary, setCurrentSummary] = useState<WikiSummary | null>(null);

    // keep track of search bar input
    const [input, setInput] = useState("");

    // keep track of nav bar tab state
    const [currentNavTab, setCurrentNavTab] = useState<NavTab>(NavTab.Home);

    // keep track of whether the context menu is open or closed
    const [contextMenuState, setContextMenuState] = useState<ContextMenuState>({
        open: false,
        type: ContextMenuType.Canvas,
        mobile: window.innerWidth < 1100,
        x: 0,
        y: 0,
    });
    window.onresize = () => {
        if (window.innerWidth < 1100) {
            if (!contextMenuState.mobile) {
                setContextMenuState({ ...contextMenuState, mobile: true });
            }
        } else {
            if (contextMenuState.mobile) {
                setContextMenuState({ ...contextMenuState, mobile: false });
            }
        }
    };

    // get reference to selection so that we can use the current value in the vis event listeners
    // otherwise, the value lags behind
    const selectionRef = useRef(selection);

    // so that we only register event listeners once
    const completionRef = useRef(false);

    // ----- initialize visualization and neovis object -----
    useEffect(() => {
        const vis = createConfig(containerId, serverDatabase, serverURI, serverUser, serverPassword);
        vis.render();
        setVis(vis);

        // create event listeners once the visualization is rendered
        vis?.registerOnEvent(NeoVisEvents.CompletionEvent, (e) => {
            if (!completionRef.current) {
                completionRef.current = true;

                const updateSelectionState = (nodeIds: IdType[]) => {
                    // update selection
                    setSelection(nodeIds);
                    selectionRef.current = nodeIds;

                    // update selection labels
                    var labels = vis.nodes
                        .get()
                        .filter((node: any) => (nodeIds ? nodeIds.includes(node.id) : ""))
                        .map(({ label }: { label?: any }) => {
                            return label;
                        });
                    setSelectionLabels(labels);
                };

                // 1. listener for "select"
                vis.network?.on("select", (e) => {
                    var nodeIds = vis.network?.getSelectedNodes();
                    if (nodeIds) {
                        updateSelectionState(nodeIds);
                    }
                });

                // 2. listener for "click"
                vis.network?.on("click", (click) => {
                    setContextMenuState({
                        open: false,
                        type: ContextMenuType.Canvas,
                        mobile: window.innerWidth < 1100,
                        x: 0,
                        y: 0,
                    });
                });

                // 3. listener for "double click"
                vis.network?.on("doubleClick", (click) => {
                    // if there's a node under the cursor, update visualization with its links
                    if (click.nodes.length > 0) {
                        const nodeId = click.nodes[0];
                        var cypher = `MATCH (p1: Page)-[l: LINKS_TO]-(p2: Page) WHERE ID(p1) = ${nodeId} RETURN p1, l, p2`;
                        vis?.updateWithCypher(cypher);
                    }
                });

                // 4. listener for "right click"
                vis.network?.on("oncontext", (click) => {
                    click.event.preventDefault();

                    // TODO: figure out why click.nodes is not accurate on right click
                    // get adjusted coordinates to place the context menu
                    var rect = click.event.target.getBoundingClientRect();
                    let correctedX = click.event.x - rect.x;
                    let correctedY = click.event.y - rect.y;

                    var type = ContextMenuType.Canvas;
                    // check if there's a node under the cursor
                    var nodeId = vis.network?.getNodeAt({ x: correctedX, y: correctedY });
                    if (nodeId) {
                        // select node that was right-clicked
                        if (selectionRef.current) {
                            vis.network?.selectNodes([...selectionRef.current, nodeId]);
                        } else {
                            vis.network?.selectNodes([nodeId]);
                        }

                        // update selection state
                        const nodeIds = vis.network?.getSelectedNodes();
                        if (nodeIds) {
                            updateSelectionState(nodeIds);
                            nodeIds.length > 1 ? (type = ContextMenuType.Nodes) : (type = ContextMenuType.Node);
                        }
                    } else {
                        type = ContextMenuType.Canvas;
                    }

                    setContextMenuState({
                        open: true,
                        type: type,
                        mobile: window.screen.width < 1100,
                        x: correctedX,
                        y: correctedY,
                    });
                });
            }
        });
    }, [containerId, serverDatabase, serverURI, serverUser, serverPassword]);

    // ----- execute cypher query when user inputs search, update visualization -----
    const createNewGraph = () => {
        // TODO: replace this with something that does not open the DB up to an injection attack
        var cypher =
            'CALL { MATCH (p:Page) WHERE apoc.text.levenshteinSimilarity(p.title, "' +
            input +
            '") > 0.65 RETURN p.title as title ORDER BY apoc.text.levenshteinSimilarity(p.title, "' +
            input +
            '") DESC LIMIT 1 } MATCH (p1:Page)-[l:LINKS_TO]-(p2:Page) WHERE p1.title = title RETURN p1, l, p2';
        // TODO: only render if the query returns > 0 nodes, otherwise tell user no nodes were found
        vis?.renderWithCypher(cypher);
        vis?.network?.moveTo({ position: { x: 0, y: 0 } });
    };

    const addToGraph = () => {
        var cypher =
            'CALL { MATCH (p:Page) WHERE apoc.text.levenshteinSimilarity(p.title, "' +
            input +
            '") > 0.65 RETURN p.title as title ORDER BY apoc.text.levenshteinSimilarity(p.title, "' +
            input +
            '") DESC LIMIT 1 } MATCH (p1:Page)-[l:LINKS_TO]-(p2:Page) WHERE p1.title = title RETURN p1, l, p2';
        vis?.updateWithCypher(cypher);
        vis?.network?.moveTo({ position: { x: 0, y: 0 } });
    };

    return (
        <>
            {/* graph visualization */}
            <StyledCanvas theme={{ expanded: visIsExpanded }} id="canvas">
                <div id={containerId} />
                <img
                    src={
                        visIsExpanded
                            ? darkMode
                                ? "icons/collapse-white.png"
                                : "icons/collapse.png"
                            : darkMode
                            ? "icons/expand-white.png"
                            : "icons/expand.png"
                    }
                    alt={visIsExpanded ? "Collapse visualization button" : "Expand visualization button"}
                    className="vis-expand-button"
                    onClick={() => setVisIsExpanded(!visIsExpanded)}
                />
                {contextMenuState.mobile && (
                    <img
                        src={
                            contextMenuState.open
                                ? darkMode
                                    ? "icons/close-white.png"
                                    : "icons/close.png"
                                : darkMode
                                ? "icons/kebab-white.png"
                                : "icons/kebab.png"
                        }
                        alt={visIsExpanded ? "Collapse visualization button" : "Expand visualization button"}
                        className="mobile-context-button"
                        onClick={() => {
                            var type;
                            if (selection.length === 0) {
                                type = ContextMenuType.Canvas;
                            } else if (selection.length === 1) {
                                type = ContextMenuType.Node;
                            } else {
                                type = ContextMenuType.Nodes;
                            }
                            setContextMenuState({ ...contextMenuState, open: !contextMenuState.open, type: type });
                        }}
                    />
                )}
                <input
                    type="submit"
                    value="Stabilize"
                    id="stabilize-button"
                    onClick={() => {
                        vis?.stabilize();
                    }}
                />
                <input type="submit" value="Center" id="center-button" onClick={() => vis?.network?.fit()} />
                <ContextMenu
                    vis={vis}
                    darkMode={darkMode}
                    state={contextMenuState}
                    setState={setContextMenuState}
                    selection={selection}
                    setSelection={setSelection}
                    selectionLabels={selectionLabels}
                    setSelectionLabels={setSelectionLabels}
                    summaries={summaries}
                    setSummaries={setSummaries}
                    setCurrentSummary={setCurrentSummary}
                />
            </StyledCanvas>
            {/* sidebar */}
            <StyledSidebar className="sidebar">
                <NavBar currentNavTab={currentNavTab} setCurrentNavTab={setCurrentNavTab} />
                {currentNavTab === NavTab.Home && (
                    <>
                        <WikipediaSummaries
                            summaries={summaries}
                            setSummaries={setSummaries}
                            currentSummary={currentSummary}
                            setCurrentSummary={setCurrentSummary}
                        />
                        <div className="search-bar">
                            Search for a Wikipedia article:
                            <br />
                            <input
                                type="search"
                                placeholder="Article title"
                                onChange={(e) => setInput(e.target.value)}
                            />
                            <br />
                            <input type="submit" value="Create new graph" onClick={createNewGraph} />
                            <input type="submit" value="Add to graph" onClick={addToGraph} />
                        </div>
                    </>
                )}
                {currentNavTab === NavTab.About && <About />}
                {currentNavTab === NavTab.UserManual && <UserManual />}
            </StyledSidebar>
        </>
    );
};

export default WikiGraph;
\$\endgroup\$
2
  • \$\begingroup\$ The WikiGraph component is doing a lot of things. I'd start by extracting the NavTab handling by using React-Router. Then by separating out the state, the component holds, by incorporating an external state management library like Redux. If it's helpful, I could write an answer where I do such things. \$\endgroup\$
    – r3dst0rm
    Jan 10, 2023 at 13:46
  • \$\begingroup\$ Ahh thank you @r3dst0rm! I thought my post was lost in the void! I actually refactored my code since to extract the sidebar into its own component, but I 100% agree with you that it still is doing a lot! I've heard of Redux, it seemed a little intimidating to get into, but I'll definitely look into it now—and an answer like that would be REALLY appreciated!! One thing about my NavTab component is that it doesn't route to a new page, rather just changes the view on the sidebar — can React-Router still handle that for me? \$\endgroup\$
    – Janice Lee
    Jan 11, 2023 at 14:39

1 Answer 1

2
\$\begingroup\$

Took me a while to find the time to write a proper review.

General

I like your project and what you are trying to achieve with it. In order to keep it maintainable and understandable you might want to consider the following recommendations:

Reduce nesting

I noticed places where the so called arrow anti pattern is noticable. Try to reduce nesting, this can be done by checking for values not being present rather than checking for values being present.

Consider this:

// ✅
const do = () => {
    if (value === null) {
        return;
    }
    
    if (valueTwo === null) {
        return;
    }
    
    // Do some stuff with value and valueTwo as it is now present
}

// vs.
// ❌
const dont = () => {
    if (value) {
        if (valueTwo) {
            // work on value and valueTwo
        }
    }
}

Avoid direct DOM manipulation

To set the dark mode class you can use a style sheet and directly set it at the most outer div you have inside the App component.

Something like this:

const [darkMode, setDarkMode] = useState(window.matchMedia("(prefers-color-scheme: dark)").matches);

return <div className={darkMode ? "dark" : "light"}>{/* ... */}</div>

Avoid direct DOM manipulation, as it can have negative side effects on your React experience and ultimately that's what React is for, keeping your UI up-to-date with the Application state of the App.

WikiGraph.tsx

As I already pointed out in the comments, the WikiGraph.tsx file is doing a lot of things. So I'd start to extract things out of there.

After thinking about it, you might want to start to factor out general stuff without external state management libraries. If you are still curious about it though, I'd highly recommend to you and try the getting-started of Redux: https://redux.js.org/introduction/getting-started

From the naming, my assumption of this component is, that it is responsible for rendering the graph and only the graph.

Given that, let's have a look at the currentNavTab and setCurrentNavTab state first.

Sidebar

To get rid of the sidebar state we could extract a separate component let's call it Sidebar which could look something along those lines:

export const SideBar = (/* introduce your own props */) => {
    const [currentNavTab, setCurrentNavTab] = useState<NavTab>(NavTab.Home);
    
    return (<StyledSidebar className="sidebar">
        <NavBar currentNavTab={currentNavTab} setCurrentNavTab={setCurrentNavTab} />
        {currentNavTab === NavTab.Home && (
            <>
            <WikipediaSummaries
            summaries={summaries}
            setSummaries={setSummaries}
            currentSummary={currentSummary}
            setCurrentSummary={setCurrentSummary}
            />
            <div className="search-bar">
                Search for a Wikipedia article:
                <br />
                <input
                type="search"
                placeholder="Article title"
                onChange={(e) => setInput(e.target.value)}
                />
                <br />
                <input type="submit" value="Create new graph" onClick={createNewGraph} />
                <input type="submit" value="Add to graph" onClick={addToGraph} />
            </div>
            </>
            )}
            {currentNavTab === NavTab.About && <About />}
            {currentNavTab === NavTab.UserManual && <UserManual />}
        </StyledSidebar>);
}

The problem here is in general, that the sidebar is tightly coupled with the graph component, therefore here are some methods missing. A simple solution would be to pass those methods via props to this component, which I think would be fine at this point in time - but in the future you might want to re-evaluate this decision.

Example, but not complete, interface:

interface SideBarProps {
    summaries: WikiSummary[];
    currentSummary: WikiSummary | null;
    setSummaries: (e: WikiSummary | null) => void;
    // ... and so on
}

What todo with the vis object

For now, it might be sufficient to extract it out of the component, by something like this:

// vis.ts
const createVis = () => createConfig(containerId, serverDatabase, serverURI, serverUser, serverPassword);
let _vis: NeoVis | undefined;
const getOrCreateVis = () => _vis === undefined ? createVis() : _vis;

export getOrCreateVis;

Hint: In a later point in time you can consider to move it into a global statemanagement library like Redux where you business logic is then held in reducers.

This would move the responsibility of creating the Vis object into the getOrCreateVis method. You might want to extend/split/refactor this method to your needs. Thus it can be use like this:

// wikigraph.tsx omitting not relevant code
const WikiGraph = (/*...*/) => {
    useEffect(() => {
        getOrCreateVis().registerOnEvent(NeoVisEvents.CompletionEvent, (e) => {/**..*/});
    });
}

From there it is desirable to extract the registerOnEvent method as well and just pass the methods you want to register. I'd scribble this like this:

createVis({ select: () => { /* do your stuff on the select event */ }, click: () => {/* ... */} });

Events

The events like select and so on, looks a bit odd to me, especially the select event. That's because I'd assume that the event would return the selected nodes inside the event object. Rather than checking the global vis object and asking it for the selected nodes.

In an ideal world this I'd expect something like this:

const select = (e) => updateSelectionState(e);

Assuming that those are calls are valid, the above registration call could look like this:

getOrCreateVis({
    select: updateSelectionState,
    /* ... more events like: */
    doubleClick: (click) => {
        if (click.nodes.length === 0) {
            return;
        }
        
        const nodeId = click.nodes[0];
        const cypher = `MATCH (p1: Page)-[l: LINKS_TO]-(p2: Page) WHERE ID(p1) = ${nodeId} RETURN p1, l, p2`;
        vis?.updateWithCypher(cypher);
    }
});

CreateNewGraph & addToGraph

Those methods are basically static and only depend on the input. Pull them out of the component.

// anywhere outside of wikigraph
const createNewGraph = (input) => {
    // TODO: replace this with something that does not open the DB up to an injection attack
    var cypher =
    'CALL { MATCH (p:Page) WHERE apoc.text.levenshteinSimilarity(p.title, "' +
    input +
    '") > 0.65 RETURN p.title as title ORDER BY apoc.text.levenshteinSimilarity(p.title, "' +
    input +
    '") DESC LIMIT 1 } MATCH (p1:Page)-[l:LINKS_TO]-(p2:Page) WHERE p1.title = title RETURN p1, l, p2';
    // TODO: only render if the query returns > 0 nodes, otherwise tell user no nodes were found
    vis?.renderWithCypher(cypher);
    vis?.network?.moveTo({ position: { x: 0, y: 0 } });
};

const addToGraph = (input) => {
    var cypher =
    'CALL { MATCH (p:Page) WHERE apoc.text.levenshteinSimilarity(p.title, "' +
    input +
    '") > 0.65 RETURN p.title as title ORDER BY apoc.text.levenshteinSimilarity(p.title, "' +
    input +
    '") DESC LIMIT 1 } MATCH (p1:Page)-[l:LINKS_TO]-(p2:Page) WHERE p1.title = title RETURN p1, l, p2';
    getOrCreateVis().updateWithCypher(cypher);
    getOrCreateVis().network?.moveTo({ position: { x: 0, y: 0 } });
};

// inside wikigraph
const onCreate = () => createNewGraph(input);
const onAdd = () => addToGraph(input);

/* Use our newly created sidebar component and simply pass on the props ... */
<SideBar create={onCreate} add={onAdd} />

Summary

I left out the Redux (or basically any other global state management library) part on purpose, because once you get the hang of pulling out common logic, you should be pretty fast in adapting it to such a state management framework.

That being said, don't be too scared to pull things out of the component. In my eyes, working on that, helps you to enhance your code and adapt to other things.

\$\endgroup\$
1
  • \$\begingroup\$ omg thank you SO SO SO SO much r3dst0rm!!! If I could upvote your answer 1mil times, I would!!! Thank you so much for taking the time to write such a thoughtful and helpful answer—I'm going to spend the weekend studying your comments and refactoring my code :) You are the BEST! \$\endgroup\$
    – Janice Lee
    Jan 13, 2023 at 18:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.