Skip to content

Commit

Permalink
Graph methods in JSON OT
Browse files Browse the repository at this point in the history
  • Loading branch information
peterszerzo committed Jul 24, 2020
1 parent 67b2b12 commit e3e5c4b
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 39 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"@types/react-dom": "^16.9.8",
"@types/uuid": "^8.0.0",
"express": "^4.17.1",
"graphlib": "^2.1.8",
"jsonwebtoken": "^8.5.1",
"random-words": "^1.1.1",
"react": "^0.0.0-experimental-4c8c98ab9",
Expand All @@ -22,6 +23,8 @@
"ws": "^7.3.1"
},
"devDependencies": {
"@types/graphlib": "^2.1.6",
"@types/react-router-dom": "^5.1.5",
"concurrently": "^5.2.0"
},
"scripts": {
Expand Down
136 changes: 99 additions & 37 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
import randomWords from "random-words";
import { v4 as uuid } from "uuid";
import React, { useMemo, useState, useEffect, useCallback } from "react";
import { connectToDB, getConnection } from "./sharedb";
import { useTransition } from "./react-experimental";
import {
Flow,
Node,
insertNodeOp,
removeNodeOp,
setFlowOp,
connectOp,
} from "./flow";
import {
Link,
BrowserRouter as Router,
useHistory,
useLocation,
} from "react-router-dom";

interface Node {
text: string;
}

type Flow = {
nodes: Record<string, Node>;
edges: Array<[string | null, string]>;
};

// Custom hook for talking to a flow in ShareDB
function useFlow(config: {
id: string;
}): {
state: Flow | null;
addNode: () => void;
insertNode: () => void;
removeNode: (id: string) => void;
reset: (flow: Flow) => void;
connectNodes: (src: string, tgt: string) => void;
setFlow: (flow: Flow) => void;
isPending: boolean;
} {
// Setup
Expand Down Expand Up @@ -56,20 +55,27 @@ function useFlow(config: {

// Methods

const addNode = useCallback(() => {
doc.submitOp([{ p: ["nodes", uuid()], oi: { text: randomWords() } }]);
const insertNode = useCallback(() => {
doc.submitOp(insertNodeOp());
}, [doc]);

const removeNode = useCallback(
(id) => {
doc.submitOp([{ p: ["nodes", id], od: {} }]);
doc.submitOp(removeNodeOp(id, doc.data));
},
[doc]
);

const connectNodes = useCallback(
(src, tgt) => {
doc.submitOp(connectOp(src, tgt, doc.data));
},
[doc]
);

const reset = useCallback(
const setFlow = useCallback(
(flow) => {
doc.submitOp([{ p: [], od: doc.data, oi: flow }]);
doc.submitOp(setFlowOp(flow, doc.data));
},
[doc]
);
Expand All @@ -78,16 +84,33 @@ function useFlow(config: {

return {
state,
addNode,
insertNode,
removeNode,
reset,
setFlow,
connectNodes,
isPending,
};
}

const Flow: React.FC<{ id: string }> = ({ id }) => {
const FlowView: React.FC<{ id: string }> = ({ id }) => {
const flow = useFlow({ id });

const [selected, setSelected] = useState<string | null>(null);

const onSelect = useCallback(
(id: string) => {
if (selected === null) {
setSelected(id);
} else if (selected === id) {
setSelected(null);
} else {
flow.connectNodes(selected, id);
setSelected(null);
}
},
[selected, setSelected]
);

if (flow.state === null) {
return <p>Loading...</p>;
}
Expand All @@ -97,7 +120,7 @@ const Flow: React.FC<{ id: string }> = ({ id }) => {
<main>
<button
onClick={() => {
flow.addNode();
flow.insertNode();
}}
>
Add
Expand All @@ -106,31 +129,47 @@ const Flow: React.FC<{ id: string }> = ({ id }) => {
onClick={() => {
fetch("/flow.json")
.then((res) => res.json())
.then((flowData) => {
flow.reset(flowData);
.then((flowData: Flow) => {
flow.setFlow(flowData);
});
}}
>
Import flow
</button>
<button
onClick={() => {
flow.reset({
flow.setFlow({
nodes: {},
edges: [],
});
}}
>
Reset
</button>
{Object.keys(flow.state.nodes).map((k) => (
<NodeView
key={k}
onRemove={flow.removeNode}
id={k}
node={flow.state.nodes[k]}
/>
))}
<div className="row mt">
<div>
<h3>Nodes</h3>
{Object.keys(flow.state.nodes).map((k) => (
<NodeView
key={k}
onRemove={flow.removeNode}
onSelect={onSelect}
id={k}
node={flow.state.nodes[k]}
activeId={selected}
/>
))}
</div>
<div>
<h3>Edges</h3>
{flow.state.edges.map(([src, tgt]) => (
<p>
{src ? src.slice(0, 6) : "root"} -{" "}
{tgt ? tgt.slice(0, 6) : "root"}
</p>
))}
</div>
</div>
</main>
{flow.isPending && <div className="overlay" />}
</>
Expand All @@ -142,12 +181,16 @@ const NodeView = React.memo(
id,
node,
onRemove,
onSelect,
activeId,
}: {
id: string;
node: Node;
onRemove: (id: string) => void;
onSelect: (id: string) => void;
activeId: string | null;
}) => (
<div className="node">
<div className={`node ${id === activeId ? "node--active" : ""}`}>
<button
className="remove-button"
onClick={() => {
Expand All @@ -156,14 +199,33 @@ const NodeView = React.memo(
>
×
</button>
<p>
{node.text || "unset"} {Math.round(Math.random() * 1000)}
</p>
<div>
<p>
{node.text || "unset"}{" "}
<small>{Math.round(Math.random() * 1000)}</small>
</p>
<p>
<small>{id.slice(0, 6)}..</small>
</p>
</div>
<button
onClick={() => {
onSelect(id);
}}
>
{activeId === id
? "Deselect"
: activeId !== null
? "Connect"
: "Select"}
</button>
</div>
),
(prevProps, nextProps) =>
prevProps.id === nextProps.id &&
prevProps.onRemove === nextProps.onRemove &&
prevProps.onSelect === nextProps.onSelect &&
prevProps.activeId === nextProps.activeId &&
JSON.stringify(prevProps.node) === JSON.stringify(nextProps.node)
);

Expand Down Expand Up @@ -209,7 +271,7 @@ const App = () => {
New flow
</button>
</nav>
<Flow id={id} />
<FlowView id={id} />
</div>
);
};
Expand Down
84 changes: 84 additions & 0 deletions src/flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import randomWords from "random-words";
import { Graph, alg } from "graphlib";
import { v4 as uuid } from "uuid";

export interface Node {
text: string;
}

export type Flow = {
nodes: Record<string, Node>;
edges: Array<[string | null, string]>;
};

export interface Op {
p: Array<any>;
li?: any;
ld?: any;
od?: any;
oi?: any;
}

const toGraphlib = (flow: Flow): Graph => {
// create a graph with the existing nodes and edges
const g = new Graph({ directed: true, multigraph: false });
Object.keys(flow.nodes).forEach((key) => {
g.setNode(key);
});
flow.edges.forEach(([src, tgt]) => {
g.setEdge(src, tgt);
});
return g;
};

export const insertNodeOp = (): Array<Op> => [
{ p: ["nodes", uuid()], oi: { text: randomWords() } },
];

export const removeNodeOp = (id: string, flow: Flow): Array<Op> => {
const graph = toGraphlib(flow);
const [, ...children] = alg.preorder(graph, [id]);
const affectedNodes = [id, ...children];
const affectedEdgeIndices = flow.edges
.map(([src, tgt], index) =>
affectedNodes.includes(src) || affectedNodes.includes(tgt) ? index : null
)
.filter((val) => val !== null)
// Sort in descending order so that indices don't shift after each ShareDB operation
.sort((a, b) => b - a);
return [
{ p: ["nodes", id], od: flow.nodes[id] },
...children.map((childId) => ({
p: ["nodes", childId],
od: flow.nodes[childId],
})),
...affectedEdgeIndices.map((edgeIndex) => ({
p: ["edges", edgeIndex],
ld: flow.edges[edgeIndex],
})),
];
};

export const setFlowOp = (flow: Flow, prevFlow: Flow): Array<Op> => [
{ p: [], od: prevFlow, oi: flow },
];

export const connectOp = (src: string, tgt: string, flow: Flow): Array<Op> => {
if (src === tgt) {
return [];
}
if (flow.edges.find(([s, t]) => s === src && t === tgt)) {
return [];
}
const graph = toGraphlib(flow);
graph.setEdge(src, tgt);
if (!alg.isAcyclic(graph)) {
return [];
}
return [
{
p: ["edges", flow.edges.length],
li: [src, tgt],
},
];
};
Loading

0 comments on commit e3e5c4b

Please sign in to comment.