diff --git a/src/__tests__/__snapshots__/render.test.tsx.snap b/src/__tests__/__snapshots__/render.test.tsx.snap new file mode 100644 index 0000000..472b466 --- /dev/null +++ b/src/__tests__/__snapshots__/render.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderToDot render to container subgraph test 1`] = ` +digraph { + subgraph "test" { + "a"; + "b"; + "a" -> "b"; + } +} +`; diff --git a/src/__tests__/render-to-dot.spec.tsx b/src/__tests__/render-to-dot.spec.tsx index fb41ac5..9e439a9 100644 --- a/src/__tests__/render-to-dot.spec.tsx +++ b/src/__tests__/render-to-dot.spec.tsx @@ -45,9 +45,4 @@ describe('renderToDot', () => { ); expect(dot).toBeValidDotAndMatchSnapshot(); }); - - it('render to be blank string', () => { - const dot = renderToDot(<>); - expect(dot).toBe(''); - }); }); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx new file mode 100644 index 0000000..ea6eb7b --- /dev/null +++ b/src/__tests__/render.test.tsx @@ -0,0 +1,41 @@ +/* eslint-disable jest/expect-expect */ +import React from 'react'; +import 'jest-graphviz'; +import { digraph, toDot } from 'ts-graphviz'; +import { Edge } from '../components/Edge'; +import { Node } from '../components/Node'; +import { renderExpectToThrow } from '../components/__tests__/utils/renderExpectToThrow'; +import { NoContainerErrorMessage } from '../errors'; +import { render } from '../render'; + +describe('renderToDot', () => { + describe('no container error', () => { + test('Fragment', () => { + renderExpectToThrow(<>, NoContainerErrorMessage); + }); + + test('Node', () => { + renderExpectToThrow(, NoContainerErrorMessage); + }); + + test('Edge', () => { + renderExpectToThrow(, NoContainerErrorMessage); + }); + }); + + it('render to container subgraph test', () => { + const nodes = ['a', 'b']; + const G = digraph(); + const subgraph = render( + <> + {nodes.map((id) => ( + + ))} + + , + G.subgraph('test'), + ); + expect(G.subgraph('test')).toEqual(subgraph); + expect(toDot(G)).toBeValidDotAndMatchSnapshot(); + }); +}); diff --git a/src/components/ClusterPortal.tsx b/src/components/ClusterPortal.tsx index 6051f7a..16b2852 100644 --- a/src/components/ClusterPortal.tsx +++ b/src/components/ClusterPortal.tsx @@ -1,22 +1,25 @@ import React, { FC, useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; -import { Cluster } from '../contexts/Cluster'; +import { CurrentCluster } from '../contexts/CurrentCluster'; import { ClusterMap } from '../contexts/ClusterMap'; -import { useRootCluster } from '../hooks/use-root-cluster'; -import { ClusterPortalComponentProps } from '../types'; +import { useContainerCluster } from '../hooks/use-container-cluster'; +import { ClusterPortalProps } from '../types'; -export const ClusterPortal: FC = ({ children, name }) => { - const root = useRootCluster(); +/** + * ClusterPortal component. + */ +export const ClusterPortal: FC = ({ children, id }) => { + const container = useContainerCluster(); const map = useContext(ClusterMap); - const cluster = useMemo(() => (name ? map.get(name) ?? root : root), [root, map, name]); - return {children}; + const cluster = useMemo(() => (id ? map.get(id) ?? container : container), [container, map, id]); + return {children}; }; ClusterPortal.displayName = 'ClusterPortal'; ClusterPortal.defaultProps = { - name: undefined, + id: undefined, }; ClusterPortal.propTypes = { - name: PropTypes.string, + id: PropTypes.string, }; diff --git a/src/components/Digraph.tsx b/src/components/Digraph.tsx index fd3006b..b77c5d5 100644 --- a/src/components/Digraph.tsx +++ b/src/components/Digraph.tsx @@ -1,22 +1,25 @@ import React, { FC, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { RootCluster } from '../contexts/RootCluster'; -import { Cluster } from '../contexts/Cluster'; +import { ContainerCluster } from '../contexts/ContainerCluster'; +import { CurrentCluster } from '../contexts/CurrentCluster'; import { useDigraph } from '../hooks/use-digraph'; import { useRenderedID } from '../hooks/use-rendered-id'; -import { useRootCluster } from '../hooks/use-root-cluster'; +import { useContainerCluster } from '../hooks/use-container-cluster'; import { DuplicatedRootClusterErrorMessage } from '../errors'; import { useClusterMap } from '../hooks/use-cluster-map'; -import { RootClusterComponentProps } from '../types'; +import { RootClusterProps } from '../types'; -export const Digraph: FC = ({ children, label, ...props }) => { - const root = useRootCluster(); - if (root !== null) { +/** + * `Digraph` component. + */ +export const Digraph: FC = ({ children, label, ...options }) => { + const container = useContainerCluster(); + if (container !== null) { throw Error(DuplicatedRootClusterErrorMessage); } const renderedLabel = useRenderedID(label); - if (renderedLabel !== undefined) Object.assign(props, { label: renderedLabel }); - const digraph = useDigraph(props); + if (renderedLabel !== undefined) Object.assign(options, { label: renderedLabel }); + const digraph = useDigraph(options); const clusters = useClusterMap(); useEffect(() => { if (digraph.id !== undefined) { @@ -24,9 +27,9 @@ export const Digraph: FC = ({ children, label, ...pro } }, [clusters, digraph]); return ( - - {children} - + + {children} + ); }; diff --git a/src/components/Edge.tsx b/src/components/Edge.tsx index 9db88a5..e75ce65 100644 --- a/src/components/Edge.tsx +++ b/src/components/Edge.tsx @@ -1,14 +1,17 @@ -import React, { FC } from 'react'; +import { VFC } from 'react'; import PropTypes from 'prop-types'; import { useEdge } from '../hooks/use-edge'; import { useRenderedID } from '../hooks/use-rendered-id'; -import { EdgeComponentProps } from '../types'; +import { EdgeProps } from '../types'; -export const Edge: FC = ({ children, label, ...props }) => { +/** + * `Edge` component. + */ +export const Edge: VFC = ({ targets, label, ...options }) => { const renderedLabel = useRenderedID(label); - if (renderedLabel !== undefined) Object.assign(props, { label: renderedLabel }); - useEdge(props); - return <>{children}; + if (renderedLabel !== undefined) Object.assign(options, { label: renderedLabel }); + useEdge(targets, options); + return null; }; Edge.displayName = 'Edge'; diff --git a/src/components/Graph.tsx b/src/components/Graph.tsx index 6c038bf..f102313 100644 --- a/src/components/Graph.tsx +++ b/src/components/Graph.tsx @@ -1,22 +1,24 @@ import React, { FC, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { RootCluster } from '../contexts/RootCluster'; -import { Cluster } from '../contexts/Cluster'; +import { ContainerCluster } from '../contexts/ContainerCluster'; +import { CurrentCluster } from '../contexts/CurrentCluster'; import { useGraph } from '../hooks/use-graph'; import { useRenderedID } from '../hooks/use-rendered-id'; -import { useRootCluster } from '../hooks/use-root-cluster'; +import { useContainerCluster } from '../hooks/use-container-cluster'; import { DuplicatedRootClusterErrorMessage } from '../errors'; import { useClusterMap } from '../hooks/use-cluster-map'; -import { RootClusterComponentProps } from '../types'; - -export const Graph: FC = ({ children, label, ...props }) => { - const root = useRootCluster(); - if (root !== null) { +import { RootClusterProps } from '../types'; +/** + * `Graph` component. + */ +export const Graph: FC = ({ children, label, ...options }) => { + const container = useContainerCluster(); + if (container !== null) { throw Error(DuplicatedRootClusterErrorMessage); } const renderedLabel = useRenderedID(label); - if (renderedLabel !== undefined) Object.assign(props, { label: renderedLabel }); - const graph = useGraph(props); + if (renderedLabel !== undefined) Object.assign(options, { label: renderedLabel }); + const graph = useGraph(options); const clusters = useClusterMap(); useEffect(() => { if (graph.id !== undefined) { @@ -24,9 +26,9 @@ export const Graph: FC = ({ children, label, ...props } }, [clusters, graph]); return ( - - {children} - + + {children} + ); }; diff --git a/src/components/Node.tsx b/src/components/Node.tsx index c664db0..5580dad 100644 --- a/src/components/Node.tsx +++ b/src/components/Node.tsx @@ -1,17 +1,20 @@ -import React, { FC } from 'react'; +import { VFC } from 'react'; import PropTypes from 'prop-types'; import { useNode } from '../hooks/use-node'; import { useRenderedID } from '../hooks/use-rendered-id'; -import { NodeComponentProps } from '../types'; +import { NodeProps } from '../types'; -export const Node: FC = ({ children, label, xlabel, ...props }) => { +/** + * `Node` component. + */ +export const Node: VFC = ({ id, label, xlabel, ...options }) => { const renderedLabel = useRenderedID(label); const renderedXlabel = useRenderedID(xlabel); - if (renderedLabel !== undefined) Object.assign(props, { label: renderedLabel }); - if (renderedXlabel !== undefined) Object.assign(props, { xlabel: renderedXlabel }); - useNode(props); - return <>{children}; + if (renderedLabel !== undefined) Object.assign(options, { label: renderedLabel }); + if (renderedXlabel !== undefined) Object.assign(options, { xlabel: renderedXlabel }); + useNode(id, options); + return null; }; Node.displayName = 'Node'; diff --git a/src/components/Subgraph.tsx b/src/components/Subgraph.tsx index ad3c7f5..f0f867e 100644 --- a/src/components/Subgraph.tsx +++ b/src/components/Subgraph.tsx @@ -1,22 +1,24 @@ import React, { FC, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Cluster } from '../contexts/Cluster'; +import { CurrentCluster } from '../contexts/CurrentCluster'; import { useSubgraph } from '../hooks/use-subgraph'; import { useRenderedID } from '../hooks/use-rendered-id'; import { useClusterMap } from '../hooks/use-cluster-map'; -import { SubgraphComponentProps } from '../types'; - -export const Subgraph: FC = ({ children, label, ...props }) => { +import { SubgraphProps } from '../types'; +/** + * `Subgraph` component. + */ +export const Subgraph: FC = ({ children, label, ...options }) => { const renderedLabel = useRenderedID(label); - if (renderedLabel !== undefined) Object.assign(props, { label: renderedLabel }); - const subgraph = useSubgraph(props); + if (renderedLabel !== undefined) Object.assign(options, { label: renderedLabel }); + const subgraph = useSubgraph(options); const clusters = useClusterMap(); useEffect(() => { if (subgraph.id !== undefined) { clusters.set(subgraph.id, subgraph); } }, [subgraph, clusters]); - return {children}; + return {children}; }; Subgraph.displayName = 'Subgraph'; diff --git a/src/components/__tests__/utils/renderExpectToThrow.tsx b/src/components/__tests__/utils/renderExpectToThrow.tsx index 2a042ba..3c674e1 100644 --- a/src/components/__tests__/utils/renderExpectToThrow.tsx +++ b/src/components/__tests__/utils/renderExpectToThrow.tsx @@ -3,7 +3,7 @@ import React, { Component, ReactElement } from 'react'; import { render } from '../../../render'; -export function renderExpectToThrow(element: ReactElement, expectedError: string): void { +export function renderExpectToThrow(element: ReactElement, ...expectedError: string[]): void { const errors: Error[] = []; class ErrorBoundary extends Component, { hasError: boolean }> { constructor(props: Record) { @@ -28,11 +28,11 @@ export function renderExpectToThrow(element: ReactElement, expectedError: string } try { - render({element}, {}); + render({element}); } catch (e) { errors.push(e); } + expect(errors.length).toBeGreaterThanOrEqual(expectedError.length); - expect(errors.length).toBe(1); - expect(errors[0].message).toContain(expectedError); + expect(errors.map((e) => e.message)).toEqual(expect.arrayContaining(expectedError)); } diff --git a/src/contexts/Cluster.ts b/src/contexts/Cluster.ts deleted file mode 100644 index f398b98..0000000 --- a/src/contexts/Cluster.ts +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import { ICluster } from 'ts-graphviz'; - -// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -export const Cluster = React.createContext(null!); -Cluster.displayName = 'Cluster'; diff --git a/src/contexts/ContainerCluster.ts b/src/contexts/ContainerCluster.ts new file mode 100644 index 0000000..45737b9 --- /dev/null +++ b/src/contexts/ContainerCluster.ts @@ -0,0 +1,5 @@ +import React from 'react'; +import { ICluster } from 'ts-graphviz'; + +export const ContainerCluster = React.createContext(null); +ContainerCluster.displayName = 'ContainerCluster'; diff --git a/src/contexts/CurrentCluster.ts b/src/contexts/CurrentCluster.ts new file mode 100644 index 0000000..ca86503 --- /dev/null +++ b/src/contexts/CurrentCluster.ts @@ -0,0 +1,5 @@ +import React from 'react'; +import { ICluster } from 'ts-graphviz'; + +export const CurrentCluster = React.createContext(null); +CurrentCluster.displayName = 'CurrentCluster'; diff --git a/src/contexts/GraphvizContext.ts b/src/contexts/GraphvizContext.ts index 6da9c0e..31507a2 100644 --- a/src/contexts/GraphvizContext.ts +++ b/src/contexts/GraphvizContext.ts @@ -1,5 +1,9 @@ import React from 'react'; -import { IContext } from '../types'; +import { ICluster } from 'ts-graphviz'; + +export interface IContext { + container?: ICluster; +} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion export const GraphvizContext = React.createContext(null!); diff --git a/src/contexts/RootCluster.ts b/src/contexts/RootCluster.ts deleted file mode 100644 index 82757eb..0000000 --- a/src/contexts/RootCluster.ts +++ /dev/null @@ -1,6 +0,0 @@ -import React from 'react'; -import { IRootCluster } from 'ts-graphviz'; - -// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -export const RootCluster = React.createContext(null!); -RootCluster.displayName = 'RootCluster'; diff --git a/src/errors.ts b/src/errors.ts index be6c29a..b3457de 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -3,3 +3,5 @@ export const NoGraphvizContextErrorMessage = 'Cannot call useGraphvizContext outside GraphvizContext.\nBasically, you need to use the render function provided by @ts-graphviz/react.'; export const NoClusterErrorMessage = 'useCluster must be called within a cluster such as Digraph, Graph, Subgraph.'; export const DuplicatedRootClusterErrorMessage = 'RootCluster is duplicated.\nUse only one of Digraph and Graph.'; + +export const NoContainerErrorMessage = 'There are no clusters of container(Subgraph, Digraph, Graph).'; diff --git a/src/hooks/__tests__/use-root-cluster.spec.ts b/src/hooks/__tests__/use-container-cluster.spec.ts similarity index 71% rename from src/hooks/__tests__/use-root-cluster.spec.ts rename to src/hooks/__tests__/use-container-cluster.spec.ts index d7869d5..7ea8521 100644 --- a/src/hooks/__tests__/use-root-cluster.spec.ts +++ b/src/hooks/__tests__/use-container-cluster.spec.ts @@ -1,33 +1,33 @@ import { Digraph, Graph } from 'ts-graphviz'; import { renderHook } from '@testing-library/react-hooks'; import { digraph, graph, graphInSubgraph, digraphInSubgraph } from './utils/wrapper'; -import { useRootCluster } from '../use-root-cluster'; +import { useContainerCluster } from '../use-container-cluster'; -describe('useRootCluster', () => { +describe('useContainerCluster', () => { describe('get root cluster', () => { test('returns Diagram instance in digraph wrapper', () => { - const { result } = renderHook(() => useRootCluster(), { + const { result } = renderHook(() => useContainerCluster(), { wrapper: digraph(), }); expect(result.current).toBeInstanceOf(Digraph); }); test('returns Graph instance in graph wrapper', () => { - const { result } = renderHook(() => useRootCluster(), { + const { result } = renderHook(() => useContainerCluster(), { wrapper: graph(), }); expect(result.current).toBeInstanceOf(Graph); }); test('returns Graph instance in graphInSubgraph wrapper', () => { - const { result } = renderHook(() => useRootCluster(), { + const { result } = renderHook(() => useContainerCluster(), { wrapper: graphInSubgraph(), }); expect(result.current).toBeInstanceOf(Graph); }); test('returns Digraph instance in digraphInSubgraph wrapper', () => { - const { result } = renderHook(() => useRootCluster(), { + const { result } = renderHook(() => useContainerCluster(), { wrapper: digraphInSubgraph(), }); expect(result.current).toBeInstanceOf(Digraph); diff --git a/src/hooks/__tests__/use-cluster.spec.ts b/src/hooks/__tests__/use-current-cluster.spec.ts similarity index 73% rename from src/hooks/__tests__/use-cluster.spec.ts rename to src/hooks/__tests__/use-current-cluster.spec.ts index f7e5e83..1618f39 100644 --- a/src/hooks/__tests__/use-cluster.spec.ts +++ b/src/hooks/__tests__/use-current-cluster.spec.ts @@ -1,34 +1,34 @@ import { Digraph, Graph, Subgraph } from 'ts-graphviz'; import { renderHook } from '@testing-library/react-hooks'; -import { useCluster } from '../use-cluster'; +import { useCurrentCluster } from '../use-current-cluster'; import { digraph, graph, graphInSubgraph, digraphInSubgraph } from './utils/wrapper'; import { NoClusterErrorMessage } from '../../errors'; -describe('useCluster', () => { +describe('useCurrentCluster', () => { describe('get parent cluster', () => { test('returns Diagram instance in digraph wrapper', () => { - const { result } = renderHook(() => useCluster(), { + const { result } = renderHook(() => useCurrentCluster(), { wrapper: digraph(), }); expect(result.current).toBeInstanceOf(Digraph); }); test('returns Graph instance in graph wrapper', () => { - const { result } = renderHook(() => useCluster(), { + const { result } = renderHook(() => useCurrentCluster(), { wrapper: graph(), }); expect(result.current).toBeInstanceOf(Graph); }); test('returns Subgraph instance in graphInSubgraph wrapper', () => { - const { result } = renderHook(() => useCluster(), { + const { result } = renderHook(() => useCurrentCluster(), { wrapper: graphInSubgraph(), }); expect(result.current).toBeInstanceOf(Subgraph); }); test('returns Subgraph instance in digraphInSubgraph wrapper', () => { - const { result } = renderHook(() => useCluster(), { + const { result } = renderHook(() => useCurrentCluster(), { wrapper: digraphInSubgraph(), }); expect(result.current).toBeInstanceOf(Subgraph); @@ -36,7 +36,7 @@ describe('useCluster', () => { }); test('An error occurs when called outside the cluster', () => { - const { result } = renderHook(() => useCluster()); + const { result } = renderHook(() => useCurrentCluster()); expect(result.error).toStrictEqual(Error(NoClusterErrorMessage)); }); }); diff --git a/src/hooks/__tests__/use-edge.spec.ts b/src/hooks/__tests__/use-edge.spec.ts index ab3ff81..e8f3658 100644 --- a/src/hooks/__tests__/use-edge.spec.ts +++ b/src/hooks/__tests__/use-edge.spec.ts @@ -6,35 +6,35 @@ import { EdgeTargetLengthErrorMessage } from '../../errors'; describe('useEdge', () => { it('returns Edge instance in digraph wrapper', () => { - const { result } = renderHook(() => useEdge({ targets: ['a', 'b'] }), { + const { result } = renderHook(() => useEdge(['a', 'b']), { wrapper: digraph(), }); expect(result.current).toBeInstanceOf(Edge); }); it('returns an Edge instance in a digraph wrapper for grouped edge targets', () => { - const { result } = renderHook(() => useEdge({ targets: ['a', ['b1', 'b2'], 'c'] }), { + const { result } = renderHook(() => useEdge(['a', ['b1', 'b2'], 'c']), { wrapper: digraph(), }); expect(result.current).toBeInstanceOf(Edge); }); it('returns Edge instance in graph wrapper', () => { - const { result } = renderHook(() => useEdge({ targets: ['a', 'b'] }), { + const { result } = renderHook(() => useEdge(['a', 'b']), { wrapper: graph(), }); expect(result.current).toBeInstanceOf(Edge); }); it('returns an Edge instance in a graph wrapper for grouped edge targets', () => { - const { result } = renderHook(() => useEdge({ targets: ['a', ['b1', 'b2'], 'c'] }), { + const { result } = renderHook(() => useEdge(['a', ['b1', 'b2'], 'c']), { wrapper: graph(), }); expect(result.current).toBeInstanceOf(Edge); }); test('throw error if the target is less than 2', () => { - const { result } = renderHook(() => useEdge({ targets: ['a'] }), { + const { result } = renderHook(() => useEdge(['a']), { wrapper: graph(), }); expect(result.error).toStrictEqual(Error(EdgeTargetLengthErrorMessage)); diff --git a/src/hooks/__tests__/use-node.spec.ts b/src/hooks/__tests__/use-node.spec.ts index 1424d5d..4c89934 100644 --- a/src/hooks/__tests__/use-node.spec.ts +++ b/src/hooks/__tests__/use-node.spec.ts @@ -6,7 +6,7 @@ import { digraph } from './utils/wrapper'; describe('useNode', () => { it('returns Node instance', () => { - const { result } = renderHook(() => useNode({ id: 'hoge' }), { + const { result } = renderHook(() => useNode('hoge'), { wrapper: digraph(), }); expect(result.current).toBeInstanceOf(Node); diff --git a/src/hooks/use-cluster-attributes.ts b/src/hooks/use-cluster-attributes.ts index be7b060..6327f52 100644 --- a/src/hooks/use-cluster-attributes.ts +++ b/src/hooks/use-cluster-attributes.ts @@ -1,11 +1,11 @@ import { ICluster, AttributesObject } from 'ts-graphviz'; import { useEffect } from 'react'; -import { ClusterAttributesProps } from '../types'; +import { ClusterCommonAttributesProps } from '../types'; export function useClusterAttributes( cluster: ICluster, attributes: AttributesObject, - { edge, node, graph }: ClusterAttributesProps, + { edge, node, graph }: ClusterCommonAttributesProps, ): void { useEffect(() => { cluster.clear(); diff --git a/src/hooks/use-cluster.ts b/src/hooks/use-cluster.ts deleted file mode 100644 index ba4afdb..0000000 --- a/src/hooks/use-cluster.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useContext } from 'react'; -import { ICluster } from 'ts-graphviz'; -import { Cluster } from '../contexts/Cluster'; -import { NoClusterErrorMessage } from '../errors'; - -export function useCluster(): ICluster { - const cluster = useContext(Cluster); - if (cluster === null) { - throw Error(NoClusterErrorMessage); - } - return cluster; -} diff --git a/src/hooks/use-container-cluster.ts b/src/hooks/use-container-cluster.ts new file mode 100644 index 0000000..6578a0f --- /dev/null +++ b/src/hooks/use-container-cluster.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ICluster } from 'ts-graphviz'; +import { ContainerCluster } from '../contexts/ContainerCluster'; + +/** + * Return the cluster of container. + */ +export function useContainerCluster(): ICluster | null { + return useContext(ContainerCluster); +} diff --git a/src/hooks/use-current-cluster.ts b/src/hooks/use-current-cluster.ts new file mode 100644 index 0000000..d1ab4e5 --- /dev/null +++ b/src/hooks/use-current-cluster.ts @@ -0,0 +1,17 @@ +import { useContext } from 'react'; +import { ICluster } from 'ts-graphviz'; +import { CurrentCluster } from '../contexts/CurrentCluster'; +import { NoClusterErrorMessage } from '../errors'; + +/** + * Hook to get the current cluster(Digraph, Graph or Subgraph). + * + * @throws If it is out of the context of Cluster, it throws an exception. + */ +export function useCurrentCluster(): ICluster { + const cluster = useContext(CurrentCluster); + if (cluster === null) { + throw Error(NoClusterErrorMessage); + } + return cluster; +} diff --git a/src/hooks/use-digraph.ts b/src/hooks/use-digraph.ts index 19fde4a..5715daa 100644 --- a/src/hooks/use-digraph.ts +++ b/src/hooks/use-digraph.ts @@ -3,13 +3,18 @@ import { Digraph, IRootCluster } from 'ts-graphviz'; import { useGraphvizContext } from './use-graphviz-context'; import { useClusterAttributes } from './use-cluster-attributes'; import { useHasComment } from './use-comment'; -import { RootClusterProps } from '../types'; +import { RootClusterOptions } from '../types'; -export function useDigraph({ id, comment, edge, node, graph, ...attributes }: RootClusterProps = {}): IRootCluster { +/** + * `useDigraph` is a hook that creates an instance of Digraph + * according to the object given by props. + */ +export function useDigraph(options: RootClusterOptions = {}): IRootCluster { + const { id, comment, edge, node, graph, ...attributes } = options; const context = useGraphvizContext(); const digraph = useMemo(() => { const g = new Digraph(id); - context.root = g; + context.container = g; g.comment = comment; g.apply(attributes); g.attributes.node.apply(node ?? {}); @@ -21,7 +26,7 @@ export function useDigraph({ id, comment, edge, node, graph, ...attributes }: Ro useClusterAttributes(digraph, attributes, { edge, node, graph }); useEffect(() => { return (): void => { - context.root = undefined; + context.container = undefined; }; }, [context]); return digraph; diff --git a/src/hooks/use-edge.ts b/src/hooks/use-edge.ts index c669283..43b55dd 100644 --- a/src/hooks/use-edge.ts +++ b/src/hooks/use-edge.ts @@ -1,13 +1,18 @@ import { useEffect, useMemo } from 'react'; -import { IEdge } from 'ts-graphviz'; -import { useCluster } from './use-cluster'; +import { EdgeTargetLike, EdgeTargetsLike, IEdge } from 'ts-graphviz'; +import { useCurrentCluster } from './use-current-cluster'; import { EdgeTargetLengthErrorMessage } from '../errors'; import { useHasComment } from './use-comment'; import { useHasAttributes } from './use-has-attributes'; -import { EdgeProps } from '../types'; +import { EdgeOptions } from '../types'; -export function useEdge({ targets, comment, ...attributes }: EdgeProps): IEdge { - const cluster = useCluster(); +/** + * `useEdge` is a hook that creates an instance of Edge + * according to the object given by props. + */ +export function useEdge(targets: (EdgeTargetLike | EdgeTargetsLike)[], props: EdgeOptions = {}): IEdge { + const { comment, ...attributes } = props; + const cluster = useCurrentCluster(); if (targets.length < 2) { throw Error(EdgeTargetLengthErrorMessage); } diff --git a/src/hooks/use-graph.ts b/src/hooks/use-graph.ts index f84dcb4..4a2e969 100644 --- a/src/hooks/use-graph.ts +++ b/src/hooks/use-graph.ts @@ -3,13 +3,18 @@ import { Graph, IRootCluster } from 'ts-graphviz'; import { useGraphvizContext } from './use-graphviz-context'; import { useClusterAttributes } from './use-cluster-attributes'; import { useHasComment } from './use-comment'; -import { RootClusterProps } from '../types'; +import { RootClusterOptions } from '../types'; -export function useGraph({ id, comment, edge, node, graph, ...attributes }: RootClusterProps = {}): IRootCluster { +/** + * `useGraph` is a hook that creates an instance of Graph + * according to the object given by props. + */ +export function useGraph(options: RootClusterOptions = {}): IRootCluster { + const { id, comment, edge, node, graph, ...attributes } = options; const context = useGraphvizContext(); const memoGraph = useMemo(() => { const g = new Graph(id); - context.root = g; + context.container = g; g.comment = comment; g.apply(attributes); g.attributes.node.apply(node ?? {}); @@ -21,7 +26,7 @@ export function useGraph({ id, comment, edge, node, graph, ...attributes }: Root useClusterAttributes(memoGraph, attributes, { edge, node, graph }); useEffect(() => { return (): void => { - context.root = undefined; + context.container = undefined; }; }, [context]); return memoGraph; diff --git a/src/hooks/use-graphviz-context.ts b/src/hooks/use-graphviz-context.ts index 77db846..22bd15d 100644 --- a/src/hooks/use-graphviz-context.ts +++ b/src/hooks/use-graphviz-context.ts @@ -1,8 +1,12 @@ import { useContext } from 'react'; +import { ICluster } from 'ts-graphviz'; import { GraphvizContext } from '../contexts/GraphvizContext'; -import { IContext } from '../types'; import { NoGraphvizContextErrorMessage } from '../errors'; +export interface IContext { + container?: ICluster; +} + export function useGraphvizContext(): IContext { const context = useContext(GraphvizContext); if (context === null) { diff --git a/src/hooks/use-node.ts b/src/hooks/use-node.ts index 5142870..646cf8f 100644 --- a/src/hooks/use-node.ts +++ b/src/hooks/use-node.ts @@ -1,12 +1,17 @@ import { useEffect, useMemo } from 'react'; import { INode } from 'ts-graphviz'; -import { NodeProps } from '../types'; -import { useCluster } from './use-cluster'; +import { NodeOptions } from '../types'; +import { useCurrentCluster } from './use-current-cluster'; import { useHasComment } from './use-comment'; import { useHasAttributes } from './use-has-attributes'; -export function useNode({ id, comment, ...attributes }: NodeProps): INode { - const cluster = useCluster(); +/** + * `useNode` is a hook that creates an instance of Node + * according to the object given by props. + */ +export function useNode(id: string, options: NodeOptions = {}): INode { + const { comment, ...attributes } = options; + const cluster = useCurrentCluster(); const node = useMemo(() => { const n = cluster.createNode(id); n.attributes.apply(attributes); diff --git a/src/hooks/use-root-cluster.ts b/src/hooks/use-root-cluster.ts deleted file mode 100644 index 7398d3b..0000000 --- a/src/hooks/use-root-cluster.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useContext } from 'react'; -import { IRootCluster } from 'ts-graphviz'; -import { RootCluster } from '../contexts/RootCluster'; - -export function useRootCluster(): IRootCluster { - return useContext(RootCluster); -} diff --git a/src/hooks/use-subgraph.ts b/src/hooks/use-subgraph.ts index 67613e9..34a77ee 100644 --- a/src/hooks/use-subgraph.ts +++ b/src/hooks/use-subgraph.ts @@ -1,21 +1,33 @@ import { useMemo, useEffect } from 'react'; -import { ISubgraph } from 'ts-graphviz'; -import { SubgraphProps } from '../types'; -import { useCluster } from './use-cluster'; +import { Subgraph, ISubgraph } from 'ts-graphviz'; +import { SubgraphOptions } from '../types'; +import { useCurrentCluster } from './use-current-cluster'; import { useClusterAttributes } from './use-cluster-attributes'; import { useHasComment } from './use-comment'; +import { useGraphvizContext } from './use-graphviz-context'; -export function useSubgraph({ id, comment, edge, node, graph, ...attributes }: SubgraphProps = {}): ISubgraph { - const cluster = useCluster(); +/** + * `useSubgraph` is a hook that creates an instance of Subgraph + * according to the object given by props. + */ +export function useSubgraph(props: SubgraphOptions = {}): ISubgraph { + const { id, comment, edge, node, graph, ...attributes } = props; + const context = useGraphvizContext(); + const cluster = useCurrentCluster(); const subgraph = useMemo(() => { - const g = cluster.createSubgraph(id); + const g = new Subgraph(id); + if (cluster !== null) { + cluster.addSubgraph(g); + } else if (!context.container) { + context.container = g; + } g.comment = comment; g.apply(attributes); g.attributes.node.apply(node ?? {}); g.attributes.edge.apply(edge ?? {}); g.attributes.graph.apply(graph ?? {}); return g; - }, [cluster, id, comment, edge, node, graph, attributes]); + }, [context, cluster, id, comment, edge, node, graph, attributes]); useHasComment(subgraph, comment); useClusterAttributes(subgraph, attributes, { edge, node, graph }); useEffect(() => { diff --git a/src/index.ts b/src/index.ts index 3e4c057..ca3ed08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,12 @@ export * from './types'; export * from './labels'; -export * from './hooks/use-cluster'; -export * from './hooks/use-cluster-map'; -export * from './hooks/use-graphviz-context'; +export * from './hooks/use-container-cluster'; +export * from './hooks/use-current-cluster'; export * from './hooks/use-digraph'; export * from './hooks/use-graph'; export * from './hooks/use-subgraph'; export * from './hooks/use-edge'; export * from './hooks/use-node'; -export * from './hooks/use-rendered-id'; -export * from './hooks/use-root-cluster'; export * from './components/Graph'; export * from './components/Digraph'; export * from './components/Subgraph'; diff --git a/src/labels.ts b/src/labels.ts index ce52911..d16342f 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -3,88 +3,91 @@ import { AttributesValue } from 'ts-graphviz'; type ValueOf = T[keyof T]; -export type TableProps = { - ALIGN?: 'CENTER' | 'LEFT' | 'RIGHT'; // "CENTER|LEFT|RIGHT" - BGCOLOR?: AttributesValue; // "color" - BORDER?: AttributesValue; // "value" - CELLBORDER?: AttributesValue; // "value" - CELLPADDING?: AttributesValue; // "value" - CELLSPACING?: AttributesValue; // "value" - COLOR?: AttributesValue; // "color" - COLUMNS?: AttributesValue; // "value" - FIXEDSIZE?: true; // "FALSE|TRUE" - GRADIENTANGLE?: AttributesValue; // "value" - HEIGHT?: AttributesValue; // "value" - HREF?: AttributesValue; // "value" - ID?: AttributesValue; // "value" - PORT?: AttributesValue; // "portName" - ROWS?: AttributesValue; // "value" - SIDES?: AttributesValue; // "value" - STYLE?: AttributesValue; // "value" - TARGET?: AttributesValue; // "value" - TITLE?: AttributesValue; // "value" - TOOLTIP?: AttributesValue; // "value" - VALIGN?: 'MIDDLE' | 'BOTTOM' | 'TOP'; // "MIDDLE|BOTTOM|TOP" - WIDTH?: AttributesValue; // "value" -}; - -type NoAttributes = Record; - -export type TrProps = NoAttributes; - -export type TdProps = { - ALIGN?: 'CENTER' | 'LEFT' | 'RIGHT' | 'TEXT'; // "CENTER|LEFT|RIGHT|TEXT" - BALIGN?: 'CENTER' | 'LEFT' | 'RIGHT'; // "CENTER|LEFT|RIGHT" - BGCOLOR?: AttributesValue; // "color" - BORDER?: AttributesValue; // "value" - CELLPADDING?: AttributesValue; // "value" - CELLSPACING?: AttributesValue; // "value" - COLOR?: AttributesValue; // "color" - COLSPAN?: AttributesValue; // "value" - FIXEDSIZE?: boolean; // "FALSE|TRUE" - GRADIENTANGLE?: AttributesValue; // "value" - HEIGHT?: AttributesValue; // "value" - HREF?: AttributesValue; // "value" - ID?: AttributesValue; // "value" - PORT?: AttributesValue; // "portName" - ROWSPAN?: AttributesValue; // "value" - SIDES?: AttributesValue; // "value" - STYLE?: AttributesValue; // "value" - TARGET?: AttributesValue; // "value" - TITLE?: AttributesValue; // "value" - TOOLTIP?: AttributesValue; // "value" - VALIGN?: 'MIDDLE' | 'BOTTOM' | 'TOP'; // "MIDDLE|BOTTOM|TOP" - WIDTH?: AttributesValue; // "value" -}; - -export type FontProps = { - COLOR?: AttributesValue; // "color" - FACE?: AttributesValue; // "fontname" - 'POINT-SIZE'?: AttributesValue; // "value" -}; - -export type BrProps = { - ALIGN?: 'CENTER' | 'LEFT' | 'RIGHT'; // "CENTER|LEFT|RIGHT" -}; - -export type ImgProps = { - SCALE?: boolean | 'WIDTH' | 'HEIGHT' | 'BOTH'; // "FALSE|TRUE|WIDTH|HEIGHT|BOTH" - SRC?: AttributesValue; // "value" -}; - -export type IProps = NoAttributes; - -export type BProps = NoAttributes; - -export type UProps = NoAttributes; - -export type OProps = NoAttributes; -export type SubProps = NoAttributes; - -export type SupProps = NoAttributes; -export type SProps = NoAttributes; -export type HrProps = NoAttributes; -export type VrProps = NoAttributes; +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace labels { + export type TableProps = { + ALIGN?: 'CENTER' | 'LEFT' | 'RIGHT'; // "CENTER|LEFT|RIGHT" + BGCOLOR?: AttributesValue; // "color" + BORDER?: AttributesValue; // "value" + CELLBORDER?: AttributesValue; // "value" + CELLPADDING?: AttributesValue; // "value" + CELLSPACING?: AttributesValue; // "value" + COLOR?: AttributesValue; // "color" + COLUMNS?: AttributesValue; // "value" + FIXEDSIZE?: true; // "FALSE|TRUE" + GRADIENTANGLE?: AttributesValue; // "value" + HEIGHT?: AttributesValue; // "value" + HREF?: AttributesValue; // "value" + ID?: AttributesValue; // "value" + PORT?: AttributesValue; // "portName" + ROWS?: AttributesValue; // "value" + SIDES?: AttributesValue; // "value" + STYLE?: AttributesValue; // "value" + TARGET?: AttributesValue; // "value" + TITLE?: AttributesValue; // "value" + TOOLTIP?: AttributesValue; // "value" + VALIGN?: 'MIDDLE' | 'BOTTOM' | 'TOP'; // "MIDDLE|BOTTOM|TOP" + WIDTH?: AttributesValue; // "value" + }; + + type NoAttributes = Record; + + export type TrProps = NoAttributes; + + export type TdProps = { + ALIGN?: 'CENTER' | 'LEFT' | 'RIGHT' | 'TEXT'; // "CENTER|LEFT|RIGHT|TEXT" + BALIGN?: 'CENTER' | 'LEFT' | 'RIGHT'; // "CENTER|LEFT|RIGHT" + BGCOLOR?: AttributesValue; // "color" + BORDER?: AttributesValue; // "value" + CELLPADDING?: AttributesValue; // "value" + CELLSPACING?: AttributesValue; // "value" + COLOR?: AttributesValue; // "color" + COLSPAN?: AttributesValue; // "value" + FIXEDSIZE?: boolean; // "FALSE|TRUE" + GRADIENTANGLE?: AttributesValue; // "value" + HEIGHT?: AttributesValue; // "value" + HREF?: AttributesValue; // "value" + ID?: AttributesValue; // "value" + PORT?: AttributesValue; // "portName" + ROWSPAN?: AttributesValue; // "value" + SIDES?: AttributesValue; // "value" + STYLE?: AttributesValue; // "value" + TARGET?: AttributesValue; // "value" + TITLE?: AttributesValue; // "value" + TOOLTIP?: AttributesValue; // "value" + VALIGN?: 'MIDDLE' | 'BOTTOM' | 'TOP'; // "MIDDLE|BOTTOM|TOP" + WIDTH?: AttributesValue; // "value" + }; + + export type FontProps = { + COLOR?: AttributesValue; // "color" + FACE?: AttributesValue; // "fontname" + 'POINT-SIZE'?: AttributesValue; // "value" + }; + + export type BrProps = { + ALIGN?: 'CENTER' | 'LEFT' | 'RIGHT'; // "CENTER|LEFT|RIGHT" + }; + + export type ImgProps = { + SCALE?: boolean | 'WIDTH' | 'HEIGHT' | 'BOTH'; // "FALSE|TRUE|WIDTH|HEIGHT|BOTH" + SRC?: AttributesValue; // "value" + }; + + export type IProps = NoAttributes; + + export type BProps = NoAttributes; + + export type UProps = NoAttributes; + + export type OProps = NoAttributes; + export type SubProps = NoAttributes; + + export type SupProps = NoAttributes; + export type SProps = NoAttributes; + export type HrProps = NoAttributes; + export type VrProps = NoAttributes; +} export const DOT = Object.freeze({ PORT: 'dot-port', @@ -112,21 +115,21 @@ declare global { namespace JSX { interface IntrinsicElements { [DOT.PORT]: { children: string }; - [DOT.TABLE]: React.PropsWithChildren; - [DOT.TR]: React.PropsWithChildren; - [DOT.TD]: React.PropsWithChildren; - [DOT.FONT]: React.PropsWithChildren; - [DOT.BR]: BrProps; - [DOT.IMG]: ImgProps; - [DOT.I]: React.PropsWithChildren; - [DOT.B]: React.PropsWithChildren; - [DOT.U]: React.PropsWithChildren; - [DOT.O]: React.PropsWithChildren; - [DOT.SUB]: React.PropsWithChildren; - [DOT.SUP]: React.PropsWithChildren; - [DOT.S]: React.PropsWithChildren; - [DOT.HR]: HrProps; - [DOT.VR]: VrProps; + [DOT.TABLE]: React.PropsWithChildren; + [DOT.TR]: React.PropsWithChildren; + [DOT.TD]: React.PropsWithChildren; + [DOT.FONT]: React.PropsWithChildren; + [DOT.BR]: labels.BrProps; + [DOT.IMG]: labels.ImgProps; + [DOT.I]: React.PropsWithChildren; + [DOT.B]: React.PropsWithChildren; + [DOT.U]: React.PropsWithChildren; + [DOT.O]: React.PropsWithChildren; + [DOT.SUB]: React.PropsWithChildren; + [DOT.SUP]: React.PropsWithChildren; + [DOT.S]: React.PropsWithChildren; + [DOT.HR]: labels.HrProps; + [DOT.VR]: labels.VrProps; } } } diff --git a/src/render.ts b/src/render.ts index 8a437d8..f6da35d 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,37 +1,116 @@ import { ReactElement, createElement } from 'react'; -import { toDot } from 'ts-graphviz'; -import { GraphvizContext } from './contexts/GraphvizContext'; +import { toDot, ICluster } from 'ts-graphviz'; + import { reconciler } from './reconciler'; +import { IContext, GraphvizContext } from './contexts/GraphvizContext'; import { ClusterMap } from './contexts/ClusterMap'; -import { IContext } from './types'; +import { ContainerCluster } from './contexts/ContainerCluster'; +import { CurrentCluster } from './contexts/CurrentCluster'; +import { NoContainerErrorMessage } from './errors'; const noop = (): void => undefined; -export function render(element: ReactElement, context: IContext): number { - const container = reconciler.createContainer({}, 0, false, null); - // Clusters - return reconciler.updateContainer( +function clusterMap(cluster?: ICluster, map: Map = new Map()): Map { + if (cluster) { + if (cluster.id) { + map.set(cluster.id, cluster); + } + cluster.subgraphs.forEach((s) => clusterMap(s, map)); + } + return map; +} + +/** + * Convert the given element to Graphviz model. + * + * @example Example of giving a cluster as a container with the second argument. + * + * ```tsx + * import React, { FC } from 'react'; + * import { digraph, toDot } from 'ts-graphviz'; + * import { Node, Subgraph, render, Edge } from '@ts-graphviz/react'; + * + * const Example: FC = () => ( + * <> + * + * + * + * + * + * + * + * ); + * + * const G = digraph((g) => render(, g)); + * console.log(toDot(G)); + * // digraph { + * // "a"; + * // subgraph "my_cluster" { + * // "b"; + * // } + * // "b" -> "a"; + * // } + * ``` + */ +export function render(element: ReactElement, container?: ICluster): ICluster { + const context: IContext = { container }; + reconciler.updateContainer( createElement( - ClusterMap.Provider, - { - value: new Map(), - }, + GraphvizContext.Provider, + { value: context }, createElement( - GraphvizContext.Provider, - { - value: context, - }, - element, + ClusterMap.Provider, + { value: clusterMap(container) }, + createElement( + ContainerCluster.Provider, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + { value: container ?? null! }, + container ? createElement(CurrentCluster.Provider, { value: container }, element) : element, + ), ), ), - container, + reconciler.createContainer({}, 0, false, null), null, noop, ); + if (!context.container) { + throw Error(NoContainerErrorMessage); + } + return context.container; } -export function renderToDot(element: ReactElement): string { - const context: IContext = {}; - render(element, context); - return context.root ? toDot(context.root) : ''; +/** + * Converts the given element to DOT language and returns it. + * + * @example + * + * ```tsx + * import React, { FC } from 'react'; + * import { Digraph, Node, Subgraph, renderToDot, Edge } from '@ts-graphviz/react'; + * + * const Example: FC = () => ( + * + * + * + * + * + * + * + * ); + * + * const dot = renderToDot(); + * console.log(dot); + * // digraph { + * // "a"; + * // subgraph "my_cluster" { + * // "b"; + * // } + * // "b" -> "a"; + * // } + * ``` + * + * @returns Rendered dot string + */ +export function renderToDot(element: ReactElement, container?: ICluster): string { + return toDot(render(element, container)); } diff --git a/src/types.ts b/src/types.ts index afaca32..dab0b35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,6 @@ import { EdgeAttributes, NodeAttributes, ClusterSubgraphAttributes, - IRootCluster, RootClusterAttributes, EdgeTargetLike, EdgeTargetsLike, @@ -11,55 +10,74 @@ import { attribute, } from 'ts-graphviz'; -export interface IContext { - root?: IRootCluster; -} - -export interface ClusterAttributesProps { +/** Common attribute values of objects under cluster */ +export interface ClusterCommonAttributesProps { + /** Attribute value for Edges */ edge?: EdgeAttributes; + /** Attribute value for Nodes */ node?: NodeAttributes; + /** Attribute value for Graphs */ graph?: ClusterSubgraphAttributes; } -export interface RootClusterProps +/** Options for RootCluster */ +export interface RootClusterOptions extends Omit, - ClusterAttributesProps, + ClusterCommonAttributesProps, IHasComment { + /** Cluster id */ id?: string; } -export interface SubgraphProps +/** Options for Subgraph */ +export interface SubgraphOptions extends Omit, - ClusterAttributesProps, + ClusterCommonAttributesProps, IHasComment { + /** Cluster id */ id?: string; } -export interface EdgeProps extends Omit, IHasComment { - targets: (EdgeTargetLike | EdgeTargetsLike)[]; -} +/** Options for Edge */ +export interface EdgeOptions extends Omit, IHasComment {} -export interface NodeProps extends Omit, IHasComment { - id: string; -} +/** Options for Node */ +export interface NodeOptions extends Omit, IHasComment {} -export interface RootClusterComponentProps extends Omit { +/** Props for RootCluster component */ +export interface RootClusterProps extends Omit { label?: ReactElement | string; } -export interface EdgeComponentProps extends Omit { +/** Props for Edge component */ +export interface EdgeProps extends Omit { + /** Edge targets */ + targets: (EdgeTargetLike | EdgeTargetsLike)[]; + /** Edge label */ label?: ReactElement | string; } -export interface NodeComponentProps extends Omit { +/** Props for Node component */ +export interface NodeProps extends Omit { + /** Node id */ + id: string; + /** Node label */ label?: ReactElement | string; + /** Node xlabel */ xlabel?: ReactElement | string; } -export interface SubgraphComponentProps extends Omit { +/** Props for Subgraph component */ +export interface SubgraphProps extends Omit { + /** Subgraph label */ label?: ReactElement | string; } -export interface ClusterPortalComponentProps { - name?: string; +/** Props for ClusterPortal component */ +export interface ClusterPortalProps { + /** + * id of the cluster you want to target for the portal. + * If not specified, target the cluster that is the container to the portal. + */ + id?: string; }