diff --git a/package.json b/package.json index 93e130ee022..31172347f46 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "next dev", "build": "next build && next export", "test": "jest", + "watch": "jest --watchAll", "start": "next start", "lint": "next lint", "deploy": "gh-pages -d out -t true" diff --git a/src/components/Graph/index.tsx b/src/components/Graph/index.tsx index 73f9c034859..3b87efbc99c 100644 --- a/src/components/Graph/index.tsx +++ b/src/components/Graph/index.tsx @@ -7,10 +7,14 @@ import { import { Canvas, EdgeData, ElkRoot, NodeData, NodeProps } from "reaflow"; import { CustomNode } from "src/components/CustomNode"; import { NodeModal } from "src/containers/Modals/NodeModal"; -import { getEdgeNodes } from "src/containers/Editor/LiveEditor/helpers"; +import { + getEdgeNodes, + searchSubTree, +} from "src/containers/Editor/LiveEditor/helpers"; import useConfig from "src/hooks/store/useConfig"; import styled from "styled-components"; import shallow from "zustand/shallow"; +import { flattenTree, extractTree } from "src/utils/json-editor-parser"; interface LayoutProps { isWidget: boolean; @@ -40,6 +44,7 @@ const MemoizedGraph = React.memo(function Layout({ }: LayoutProps) { const json = useConfig((state) => state.json); const [nodes, setNodes] = React.useState([]); + const [mainTree, setMainTree] = React.useState([]); const [edges, setEdges] = React.useState([]); const [size, setSize] = React.useState({ width: 2000, @@ -47,17 +52,22 @@ const MemoizedGraph = React.memo(function Layout({ }); const setConfig = useConfig((state) => state.setConfig); - const [expand, layout] = useConfig( - (state) => [state.expand, state.layout], + const [expand, layout, navigationMode] = useConfig( + (state) => [state.expand, state.layout, state.navigationMode], shallow ); React.useEffect(() => { - const { nodes, edges } = getEdgeNodes(json, expand); + let parsedJson = JSON.parse(json); + if (!Array.isArray(parsedJson)) parsedJson = [parsedJson]; + const mainTree = extractTree(parsedJson); + const flatTree = flattenTree(mainTree); + const { nodes, edges } = getEdgeNodes(flatTree, expand); + setMainTree(mainTree); setNodes(nodes); setEdges(edges); - }, [json, expand]); + }, [json, expand, navigationMode]); const onInit = (ref: ReactZoomPanPinchRef) => { setConfig("zoomPanPinch", ref); @@ -75,10 +85,21 @@ const MemoizedGraph = React.memo(function Layout({ const handleNodeClick = React.useCallback( (e: React.MouseEvent, props: NodeProps) => { - setSelectedNode(props.properties.text); - openModal(); + if (navigationMode) { + const subTree = searchSubTree(mainTree, props.id); + + if (subTree.length) { + const flatTree = flattenTree(subTree); + const { nodes, edges } = getEdgeNodes(flatTree, expand); + setNodes(nodes); + setEdges(edges); + } + } else { + setSelectedNode(props.properties.text); + openModal(); + } }, - [openModal, setSelectedNode] + [expand, mainTree, navigationMode, openModal, setSelectedNode] ); const node = React.useCallback( diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 4843f67b3f6..9bbafd51bf8 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -16,6 +16,7 @@ import { AiOutlineSave, AiOutlineFileAdd, AiOutlineLink, + AiOutlineApartment, } from "react-icons/ai"; import { Tooltip } from "src/components/Tooltip"; @@ -144,8 +145,13 @@ export const Sidebar: React.FC = () => { const [shareVisible, setShareVisible] = React.useState(false); const { push } = useRouter(); - const [expand, performanceMode, layout] = useConfig( - (state) => [state.expand, state.performanceMode, state.layout], + const [expand, performanceMode, layout, navigationMode] = useConfig( + (state) => [ + state.expand, + state.performanceMode, + state.layout, + state.navigationMode, + ], shallow ); @@ -176,6 +182,15 @@ export const Sidebar: React.FC = () => { setConfig("performanceMode", !performanceMode); }; + const toggleNavigationMode = () => { + const toastMsg = navigationMode + ? "Disabled Navigation Mode" + : "Enabled Navigation Mode"; + + setConfig("navigationMode", !navigationMode); + toast(toastMsg); + }; + const toggleLayout = () => { const nextLayout = getNextLayout(layout); setConfig("layout", nextLayout); @@ -202,6 +217,13 @@ export const Sidebar: React.FC = () => { + + + + + { + it("takes a parsed JSON and extracts a tree representation of the JSON", () => { + const parsedJson = [ + { + "name": "root", + "colors": [ + "red", + "green", + "blue" + ] + } + ] + const tree = [ + { + "id": "1", + "text": { + "name": "root" + }, + "children": [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "3", + "text": "red", + "children": [], + "parent_id": "2", + "parent": false + }, + { + "id": "4", + "text": "green", + "children": [], + "parent_id": "2", + "parent": false + }, + { + "id": "5", + "text": "blue", + "children": [], + "parent_id": "2", + "parent": false + } + ] + } + ], + "parent": false, + "parent_id": null + } + ] + expect(extractTree(parsedJson)).toStrictEqual(tree) + }) + + it("simple object with two sibblings arrays", () => { + const simpleObject = { + "name": "root", + "colors": [ + "red", + "blue," + ], + "tags": [ + "good-first-issue", + "bug" + ] + } + const result = [ + { + "id": "1", + "text": { + "name": "root" + }, + "children": [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "4", + "text": "red", + "children": [], + "parent_id": "2", + "parent": false + }, + { + "id": "5", + "text": "blue,", + "children": [], + "parent_id": "2", + "parent": false + } + ] + }, + { + "id": "3", + "text": "tags", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "6", + "text": "good-first-issue", + "children": [], + "parent_id": "3", + "parent": false + }, + { + "id": "7", + "text": "bug", + "children": [], + "parent_id": "3", + "parent": false + } + ] + } + ], + "parent_id": null, + "parent": false + } + ] + expect(extractTree(simpleObject)).toStrictEqual(result) + }) + + it("simple object with no children", () => { + const simpleObject = [ + { + "first_name": "jane", + "last_name": "doe" + } + ] + const result = [ + { + "id": "1", + "text": { + "first_name": "jane", + "last_name": "doe" + }, + "children": [], + "parent_id": null, + "parent": false + } + ] + expect(extractTree(simpleObject)).toStrictEqual(result) + }) + + it("simple object with only one element inside array in children", () => { + const simpleObject = [ + { + "name": "root", + "colors": [ + "red", + ] + } + ] + const result = [ + { + "id": "1", + "text": { + "name": "root" + }, + "children": [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "3", + "text": "red", + "children": [], + "parent_id": "2", + "parent": false + } + ] + } + ], + "parent": false, + "parent_id": null + } + ] + expect(extractTree(simpleObject)).toStrictEqual(result) + }) +}) diff --git a/src/components/__tests__/utils/json-editor-parser/flattenTree.test.js b/src/components/__tests__/utils/json-editor-parser/flattenTree.test.js new file mode 100644 index 00000000000..5b6fc05f205 --- /dev/null +++ b/src/components/__tests__/utils/json-editor-parser/flattenTree.test.js @@ -0,0 +1,56 @@ +import { flattenTree } from "src/utils/json-editor-parser" + +describe("flattenTree", () => { + it("takes a tree representation of a JSON and flattens it into a set of nodes and edges", () => { + const tree = [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "3", + "text": "red", + "parent_id": "2", + "children": [], + "parent": false + } + ] + } + ] + + const flatTree = [ + { + "id": "1", + "text": "parent", + "parent": true + }, + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1" + }, + { + "id": "3", + "text": "red", + "parent_id": "2", + "parent": false + }, + { + "id": "e2-1", + "from": "2", + "to": "1" + }, + { + "id": "e2-3", + "from": "2", + "to": "3" + } + ] + + expect(flattenTree(tree)).toStrictEqual(flatTree) + }) +}) + diff --git a/src/components/__tests__/utils/json-editor-parser/generateChildren.test.js b/src/components/__tests__/utils/json-editor-parser/generateChildren.test.js new file mode 100644 index 00000000000..4cc80a2d465 --- /dev/null +++ b/src/components/__tests__/utils/json-editor-parser/generateChildren.test.js @@ -0,0 +1,166 @@ +import {privateMethods} from "src/utils/json-editor-parser" + +describe("private_filterChild", () => { + const {generateChildren} = privateMethods; + it("generates children for a simple object with nested strings in array 'colors'", () => { + const nextId = ( + (id) => () => + String(++id) + )(1) + + const simpleObject = { + "name": "root", + "colors": [ + "red", + "green", + "blue" + ] + } + + const resultChildren = [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "3", + "text": "red", + "children": [], + "parent_id": "2", + "parent": false + }, + { + "id": "4", + "text": "green", + "children": [], + "parent_id": "2", + "parent": false + }, + { + "id": "5", + "text": "blue", + "children": [], + "parent_id": "2", + "parent": false + } + ] + } + ] + + expect(generateChildren(simpleObject, nextId, "1")).toStrictEqual(resultChildren) + }) + + it("generates children for an object without children", () => { + const nextId = ( + (id) => () => + String(++id) + )(0) + + const simpleObject = { + "first_name": "jane", + "last_name": "doe" + } + const resultChildren = [] + expect(generateChildren(simpleObject, nextId)).toStrictEqual(resultChildren) + }) + it("generates children", () => { + const nextId = ( + (id) => () => + String(++id) + )(1) + + const simpleObject = { + "name": "root", + "colors": [ + "red" + ] + } + const result = [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "3", + "text": "red", + "children": [], + "parent_id": "2", + "parent": false + } + ] + } + ] + expect(generateChildren(simpleObject, nextId, "1")).toStrictEqual(result) + }) + + it("simple object with two sibblings arrays", () => { + const nextId = ( + (id) => () => + String(++id) + )(1) + + const simpleObject = { + "name": "root", + "colors": [ + "red", + "blue," + ], + "tags": [ + "good-first-issue", + "bug" + ] + } + + const result = [ + { + "id": "2", + "text": "colors", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "4", + "text": "red", + "children": [], + "parent_id": "2", + "parent": false + }, + { + "id": "5", + "text": "blue,", + "children": [], + "parent_id": "2", + "parent": false + } + ] + }, + { + "id": "3", + "text": "tags", + "parent": true, + "parent_id": "1", + "children": [ + { + "id": "6", + "text": "good-first-issue", + "children": [], + "parent_id": "3", + "parent": false + }, + { + "id": "7", + "text": "bug", + "children": [], + "parent_id": "3", + "parent": false + } + ] + } + ] + expect(generateChildren(simpleObject, nextId, "1")).toStrictEqual(result) + }) +}) diff --git a/src/containers/Editor/LiveEditor/helpers.ts b/src/containers/Editor/LiveEditor/helpers.ts index ee93b5944e3..aaa82d1a488 100644 --- a/src/containers/Editor/LiveEditor/helpers.ts +++ b/src/containers/Editor/LiveEditor/helpers.ts @@ -1,14 +1,12 @@ import { CanvasDirection, NodeData, EdgeData } from "reaflow"; -import { parser } from "src/utils/json-editor-parser"; export function getEdgeNodes( - graph: string, + elements: any, isExpanded: boolean = true ): { nodes: NodeData[]; edges: EdgeData[]; } { - const elements = parser(JSON.parse(graph)); let nodes: NodeData[] = [], edges: EdgeData[] = []; @@ -46,6 +44,24 @@ export function getEdgeNodes( }; } + +export function searchSubTree (trees: any[], id: string) { + for (let i = 0; i < trees.length; i++) { + let tree = trees[i]; + const cond = tree.id == id; + if (cond) return [tree]; + } + + for (let i = 0; i < trees.length; i++) { + let tree = trees[i]; + let result = searchSubTree(tree.children, id); + if (result.length) return result; + } + + return []; +}; + + export function getNextLayout(layout: CanvasDirection) { switch (layout) { case "RIGHT": diff --git a/src/hooks/store/useConfig.tsx b/src/hooks/store/useConfig.tsx index cb1cb77a279..5b5df80a6e5 100644 --- a/src/hooks/store/useConfig.tsx +++ b/src/hooks/store/useConfig.tsx @@ -20,6 +20,7 @@ export interface Config { hideEditor: boolean; zoomPanPinch?: ReactZoomPanPinchRef; performanceMode: boolean; + navigationMode: boolean; } const initialStates: Config = { @@ -29,6 +30,7 @@ const initialStates: Config = { expand: true, hideEditor: false, performanceMode: false, + navigationMode: false }; const useConfig = create()((set, get) => ({ diff --git a/src/utils/json-editor-parser.ts b/src/utils/json-editor-parser.ts index a17ced1a869..a6392142c7a 100644 --- a/src/utils/json-editor-parser.ts +++ b/src/utils/json-editor-parser.ts @@ -17,31 +17,20 @@ const filterValues = ([k, v]) => { return true; }; -function generateChildren(object: Object, nextId: () => string) { +function generateChildren(object: Object, nextId: () => string, parent_id: string) { if (!(object instanceof Object)) object = [object]; return Object.entries(object) .filter(filterChild) - .flatMap(([k, v]) => { - // const isObject = v instanceof Object && !Array.isArray(v); - - // if (isObject) { - // return [ - // { - // id: nextId(), - // text: k, - // parent: true, - // children: generateChildren(v, nextId), - // }, - // ]; - // } - + .map(([k, v]) => [k, v, nextId()]) + .flatMap(([k, v, id]) => { return [ { - id: nextId(), + id: id, text: k, parent: true, - children: extract(v, nextId), + parent_id, + children: extractTree(v, nextId, id), }, ]; }); @@ -58,21 +47,27 @@ function generateNodeData(object: Object | number) { return String(object); } -const extract = ( +export const extractTree = ( os: string[] | object[] | null, nextId = ( (id) => () => String(++id) - )(0) + )(0), + parent_id: string | null, ) => { if (!os) return []; - - return [os].flat().map((o) => ({ - id: nextId(), - text: generateNodeData(o), - children: generateChildren(o, nextId), - parent: false, - })); + if (parent_id == undefined) parent_id = null; + + return [os] + .flat() + .map((o) => [o, nextId()]) + .map(([o, id]) => ({ + id, + text: generateNodeData(o), + parent_id, + children: generateChildren(o, nextId, id), + parent: false, + })); }; const flatten = (xs: { id: string; children: never[] }[]) => @@ -89,11 +84,27 @@ const relationships = (xs: { id: string; children: never[] }[]) => { ]); }; +export const flattenTree = tree => { + let res:any; + if (tree[0].parent_id != null) { + const parent_node = { + id: tree[0].parent_id, text: "parent", parent: true + } + const parent_relation = { + id: `e${tree[0].id}-${tree[0].parent_id}`, from: tree[0].id, to: tree[0].parent_id + } + res = [parent_node, ...flatten(tree), parent_relation, ...relationships(tree)]; + } else { + res = [...flatten(tree), ...relationships(tree)]; + } + return res; +}; + export const parser = (input: string | string[]) => { try { if (!Array.isArray(input)) input = [input]; - const mappedElements = extract(input); + const mappedElements = extractTree(input, null); const res = [...flatten(mappedElements), ...relationships(mappedElements)]; return res; @@ -103,3 +114,7 @@ export const parser = (input: string | string[]) => { return []; } }; + +export const privateMethods = { + relationships, flatten, filterChild, generateChildren, +}