diff --git a/package.json b/package.json index 9fa98c2..e9d891a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index 3f0fa39..573a9b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,15 @@ 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, @@ -10,23 +17,15 @@ import { useLocation, } from "react-router-dom"; -interface Node { - text: string; -} - -type Flow = { - nodes: Record; - 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 @@ -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] ); @@ -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(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

Loading...

; } @@ -97,7 +120,7 @@ const Flow: React.FC<{ id: string }> = ({ id }) => {
- {Object.keys(flow.state.nodes).map((k) => ( - - ))} +
+
+

Nodes

+ {Object.keys(flow.state.nodes).map((k) => ( + + ))} +
+
+

Edges

+ {flow.state.edges.map(([src, tgt]) => ( +

+ {src ? src.slice(0, 6) : "root"} -{" "} + {tgt ? tgt.slice(0, 6) : "root"} +

+ ))} +
+
{flow.isPending &&
} @@ -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; }) => ( -
+
-

- {node.text || "unset"} {Math.round(Math.random() * 1000)} -

+
+

+ {node.text || "unset"}{" "} + {Math.round(Math.random() * 1000)} +

+

+ {id.slice(0, 6)}.. +

+
+
), (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) ); @@ -209,7 +271,7 @@ const App = () => { New flow - +
); }; diff --git a/src/flow.ts b/src/flow.ts new file mode 100644 index 0000000..22ba65c --- /dev/null +++ b/src/flow.ts @@ -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; + edges: Array<[string | null, string]>; +}; + +export interface Op { + p: Array; + 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 => [ + { p: ["nodes", uuid()], oi: { text: randomWords() } }, +]; + +export const removeNodeOp = (id: string, flow: Flow): Array => { + 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 => [ + { p: [], od: prevFlow, oi: flow }, +]; + +export const connectOp = (src: string, tgt: string, flow: Flow): Array => { + 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], + }, + ]; +}; diff --git a/src/style.css b/src/style.css index 48bbaf3..67ad3cd 100644 --- a/src/style.css +++ b/src/style.css @@ -1,3 +1,7 @@ +* { + font-family: monospace; +} + nav { padding: 20px; border-bottom: 1px solid #cecece; @@ -7,6 +11,14 @@ main { padding: 20px; } +small { + opacity: 0.8; +} + +p { + margin: 0; +} + button, a { display: inline-block; @@ -18,9 +30,18 @@ a { align-items: center; max-width: 360px; border-radius: 4px; - margin-top: 20px; - padding: 0 10px; + margin-bottom: 20px; + padding: 8px 10px; box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.2); + border: 1px solid transparent; +} + +.node--active { + border-color: blue; +} + +.node > * { + margin-right: 8px; } .remove-button { @@ -40,6 +61,20 @@ a { background-color: #dedede; } +.row { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 20px; +} + +.mt { + margin-top: 20px; +} + +.mb { + margin-bottom: 20px; +} + .overlay { position: fixed; top: 0; diff --git a/yarn.lock b/yarn.lock index 626d9b3..7678d89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1477,6 +1477,16 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/graphlib@^2.1.6": + version "2.1.6" + resolved "https://registry.yarnpkg.com/@types/graphlib/-/graphlib-2.1.6.tgz#5c7b515bfadc08d737f2e84fadbd151117c73207" + integrity sha512-os2Xj+pV/iwLkLX17LWuXdPooA4Jf4xg8WSdKPUi0tCSseP95oikcA1irOgVl3K2QYnoXrjJT3qVZeQ1uskB7g== + +"@types/history@*": + version "4.7.6" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.6.tgz#ed8fc802c45b8e8f54419c2d054e55c9ea344356" + integrity sha512-GRTZLeLJ8ia00ZH8mxMO8t0aC9M1N9bN461Z2eaRurJo6Fpa+utgCwLzI4jQHcrdzuzp5WPN9jRwpsCQ1VhJ5w== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz#4ba8ddb720221f432e443bd5f9117fd22cfd4762" @@ -1539,6 +1549,23 @@ dependencies: "@types/react" "*" +"@types/react-router-dom@^5.1.5": + version "5.1.5" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.1.5.tgz#7c334a2ea785dbad2b2dcdd83d2cf3d9973da090" + integrity sha512-ArBM4B1g3BWLGbaGvwBGO75GNFbLDUthrDojV2vHLih/Tq8M+tgvY1DSwkuNrPSwdp/GUL93WSEpTZs8nVyJLw== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-router" "*" + +"@types/react-router@*": + version "5.1.8" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.8.tgz#4614e5ba7559657438e17766bb95ef6ed6acc3fa" + integrity sha512-HzOyJb+wFmyEhyfp4D4NYrumi+LQgQL/68HvJO+q6XtuHSDvw6Aqov7sCAhjbNq3bUPgPqbdvjXC5HeB2oEAPg== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.43": version "16.9.43" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.43.tgz#c287f23f6189666ee3bebc2eb8d0f84bcb6cdb6b" @@ -4923,6 +4950,13 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== +graphlib@^2.1.8: + version "2.1.8" + resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.8.tgz#5761d414737870084c92ec7b5dbcb0592c9d35da" + integrity sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A== + dependencies: + lodash "^4.17.15" + growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"