Skip to content

Commit

Permalink
add responsive layouting, store expanded nodes in context
Browse files Browse the repository at this point in the history
  • Loading branch information
pokornyd committed Feb 20, 2025
1 parent 98c518c commit 56d9f1f
Show file tree
Hide file tree
Showing 8 changed files with 307 additions and 102 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"dev:functions": "netlify dev",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"preview": "vite preview",
"fmt": "dprint fmt"
},
Expand Down
97 changes: 63 additions & 34 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,59 +11,88 @@ import { Loader } from "./components/Loader";
const App: React.FC = () => {
const customAppContext = useAppContext();
const [loading, setLoading] = useState(true);
const [contentTypes, setContentTypes] = useState<
ContentTypeModels.ContentType[]
>([]);
const [error, setError] = useState<{ description: string; code: string } | null>(null);
const [contentTypes, setContentTypes] = useState<ContentTypeModels.ContentType[]>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);

if (customAppContext && customAppContext.isError) {
return (
<div className="flex items-center justify-center h-screen">
Error: {customAppContext.description}, error code: {customAppContext.code}
</div>
);
}
const handleNodeSelect = (nodeId: string) => {
setSelectedNodeId(nodeId);
};

useEffect(() => {
if (!customAppContext) {
setLoading(true);
return;
}

if (customAppContext.isError) {
setError({
description: customAppContext.description,
code: customAppContext.code,
});
setLoading(false);
return;
}

const fetchData = async () => {
setLoading(true);
const environmentId = customAppContext.context.environmentId;
const result = await getContentTypes(environmentId);
if (result.error) {
console.error(result.error);
} else {
try {
setLoading(true);
const result = await getContentTypes(customAppContext.context.environmentId);

if (result.error) {
throw result.error;
}

setContentTypes(result.data || []);
} catch (err) {
console.error(err);
setError({
description: "Failed to load content types",
code: "FETCH_ERROR",
});
} finally {
setLoading(false);
}
setLoading(false);
};

fetchData();
}, [customAppContext]);

return loading
? (
if (error) {
return (
<div className="flex items-center justify-center h-screen">
Error: {error.description}, error code: {error.code}
</div>
);
}

if (loading) {
return (
<div className="centered">
<Loader
title={"Just a moment"}
message={"Your content model is being loaded and layouted. This may take a while depending on your model complexity."}
title="Just a moment"
message="Your content model is being loaded and layouted. This may take a while depending on your model complexity."
/>
</div>
)
: (
<div className="flex h-screen">
{/* Sidebar */}
<div className="w-64 border-r bg-[#f3f3f3] border-gray-200 relative z-10">
<Sidebar types={contentTypes} />
</div>
{/* Canvas */}
<div className="flex-1">
<Canvas types={contentTypes} />
</div>
</div>
);
}

return (
<div className="flex h-screen">
<div className="w-64 border-r bg-[#f3f3f3] border-gray-200 relative z-10">
<Sidebar
types={contentTypes}
onTypeSelect={handleNodeSelect}
/>
</div>
<div className="flex-1">
<Canvas
types={contentTypes}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
/>
</div>
</div>
);
};

export default App;
142 changes: 109 additions & 33 deletions src/components/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
// src/components/Canvas.tsx
import React, { useState, useEffect, useCallback } from "react";
import ReactFlow, { MiniMap, Controls, Background, Node, Edge, NodeChange, applyNodeChanges } from "reactflow";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import ReactFlow, {
MiniMap,
Controls,
Background,
Node,
NodeChange,
applyNodeChanges,
ReactFlowProvider,
useReactFlow,
} from "reactflow";
import "reactflow/dist/style.css";
import { getLayoutedElements } from "../utils/layout";
import { ContentTypeNode } from "./ContentTypeNode";
import { ContentTypeElements, ContentTypeModels } from "@kontent-ai/management-sdk";
import { useExpandedNodes } from "../contexts/ExpandedNodesContext";

type ContentType = ContentTypeModels.ContentType;

type Elements = ContentTypeElements.ContentTypeElementModel[];

type ProcessedGraph = {
nodes: Array<{
id: string;
data: { label: string; elements: Elements };
type: string;
data: {
id: string;
label: string;
elements: ContentTypeElements.ContentTypeElementModel[];
};
position: { x: number; y: number };
}>;
edges: Array<{
Expand All @@ -27,16 +40,19 @@ type ProcessedGraph = {
};

const processContentTypes = (contentTypes: ContentType[]): ProcessedGraph => {
// Create nodes with a single incoming handle.
const nodes: ProcessedGraph["nodes"] = contentTypes.map((type) => ({
id: type.id,
type: "contentType", // custom node type for our component
data: {
label: type.name,
elements: type.elements,
},
position: { x: 0, y: 0 }, // position is calculated later
}));
const nodes: ProcessedGraph["nodes"] = contentTypes.map((type) => {
console.log("Creating node for type:", type.id); // Debug log
return {
id: type.id,
type: "contentType",
data: {
id: type.id,
label: type.name,
elements: type.elements,
},
position: { x: 0, y: 0 },
};
});

// process elements to create edges, each with a unique outgoing handle on the source node
const edges: ProcessedGraph["edges"] = [];
Expand Down Expand Up @@ -86,36 +102,96 @@ const nodeTypes = {

type CanvasProps = {
types: Array<ContentTypeModels.ContentType>;
selectedNodeId: string | null;
onNodeSelect: (nodeId: string) => void;
};

export const Canvas: React.FC<CanvasProps> = ({ types }) => {
// Process the content types to create initial nodes and edges.
const entities = processContentTypes(types); // assume processContentTypes now passes full data
const [nodes, setNodes] = useState<Node[]>(entities.nodes);
const [edges, setEdges] = useState<Edge[]>(entities.edges);
// Separate flow component to use hooks
const Flow: React.FC<CanvasProps> = ({ types, selectedNodeId, onNodeSelect }) => {
// Memoize the processed entities
const entities = useMemo(() => processContentTypes(types), [types]);
const { expandedNodes } = useExpandedNodes();
const reactFlowInstance = useReactFlow();
const [shouldCenter, setShouldCenter] = useState(false);

// Create a memoized function to get current nodes state
const getUpdatedNodes = useCallback((baseNodes: Node[]) => {
return baseNodes.map(node => ({
...node,
selected: node.id === selectedNodeId,
data: {
...node.data,
isExpanded: expandedNodes.has(node.id),
},
}));
}, [selectedNodeId, expandedNodes]);

// Initialize nodes
const [nodes, setNodes] = useState<Node[]>(() => {
const initialNodes = getUpdatedNodes(entities.nodes);
const { nodes: layoutedNodes } = getLayoutedElements(initialNodes, entities.edges);
return layoutedNodes;
});

// Handle expansion/collapse and selection changes
useEffect(() => {
const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges);
const updatedNodes = getUpdatedNodes(nodes);
const { nodes: layoutedNodes } = getLayoutedElements(updatedNodes, entities.edges);
setNodes(layoutedNodes);
}, []); // run only on mount
}, [expandedNodes, selectedNodeId, getUpdatedNodes]);

// Handle centering
useEffect(() => {
if (selectedNodeId && shouldCenter) {
const node = nodes.find(n => n.id === selectedNodeId);
if (node) {
reactFlowInstance.setCenter(
node.position.x + 125,
node.position.y,
{ duration: 800, zoom: 1.5 },
);
setShouldCenter(false);
}
}
}, [selectedNodeId, shouldCenter, nodes, reactFlowInstance]);

// Update shouldCenter when selection changes
useEffect(() => {
if (selectedNodeId) {
setShouldCenter(true);
}
}, [selectedNodeId]);

const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((nds) => applyNodeChanges(changes, nds));
}, []);

return (
<ReactFlow
nodes={nodes}
edges={entities.edges}
onNodesChange={onNodesChange}
nodeTypes={nodeTypes}
onNodeClick={(_, node) => {
setShouldCenter(false);
onNodeSelect(node.id);
}}
fitView
>
<MiniMap />
<Controls />
<Background color="grey" gap={20} />
</ReactFlow>
);
};

// Wrapper component to provide ReactFlow context
export const Canvas: React.FC<CanvasProps> = (props) => {
return (
<div className="w-full h-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
nodeTypes={nodeTypes}
fitView
>
<MiniMap />
<Controls />
<Background color="grey" gap={20} />
</ReactFlow>
<ReactFlowProvider>
<Flow {...props} />
</ReactFlowProvider>
</div>
);
};
31 changes: 10 additions & 21 deletions src/components/ContentTypeNode.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
// src/components/ContentTypeNode.tsx
import React, { useEffect, useRef, useState } from "react";
import React from "react";

import { SourceHandle, TargetHandle } from "./Handles";
import { ContentTypeElements } from "@kontent-ai/management-sdk";
import { NodeProps } from "reactflow";

type ContentTypeNodeData = {
label: string;
elements: ContentTypeElements.ContentTypeElementModel[];
};
import { useExpandedNodes } from "../contexts/ExpandedNodesContext";
import { ContentTypeNodeData, getFilteredElementsData } from "../utils/layout";

type ElementType = ContentTypeElements.ContentTypeElementModel["type"];

Expand Down Expand Up @@ -36,24 +33,14 @@ export const ContentTypeNode: React.FC<NodeProps<ContentTypeNodeData>> = ({
data,
selected,
}) => {
const [expanded, setExpanded] = useState(false);
const { expandedNodes, toggleNode } = useExpandedNodes();
const expanded = expandedNodes.has(data.id);

const { filteredElements, hasSnippet } = data.elements.reduce(
(acc, el) => ({
filteredElements: el.type !== "guidelines" && el.type !== "snippet"
? [...acc.filteredElements, el]
: acc.filteredElements,
hasSnippet: acc.hasSnippet || el.type === "snippet"
}),
{
filteredElements: [] as ContentTypeElements.ContentTypeElementModel[],
hasSnippet: false
}
);
const { filteredElements } = getFilteredElementsData(data);

const toggleExpanded = (e: React.MouseEvent) => {
e.stopPropagation(); // differ between dragging and clicking
setExpanded((prev) => !prev);
toggleNode(data.id);
};

const isRelationshipElement = (
Expand Down Expand Up @@ -114,7 +101,9 @@ export const ContentTypeNode: React.FC<NodeProps<ContentTypeNodeData>> = ({
</div>
),
)}
{hasSnippet && <div className="text-center text-sm font-bold p-2">Snippets</div>}
{data.elements.some(el => el.type === "snippet") && (
<div className="text-center text-sm font-bold p-2">Snippets</div>
)}
</div>
</div>
)
Expand Down
Loading

0 comments on commit 56d9f1f

Please sign in to comment.