Skip to content

Commit 4c248e3

Browse files
committed
Implement nodeLabelComponent & allowForeignObjects
Add tests Add docs
1 parent 7c67fa7 commit 4c248e3

File tree

8 files changed

+403
-163
lines changed

8 files changed

+403
-163
lines changed

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ React D3 Tree is a [React](http://facebook.github.io/react/) component that lets
1818
- [Node shapes](#node-shapes)
1919
- [Styling](#styling)
2020
- [External data sources](#external-data-sources)
21+
- [Using foreignObjects](#using-foreignobjects)
22+
- [`nodeLabelComponent`](#nodelabelcomponent)
2123
- [Recipes](#recipes)
2224

2325

@@ -82,6 +84,7 @@ class MyComponent extends React.Component {
8284
|:------------------------|:-----------------------|:------------------------------------------------------------------------------|:----------|:--------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
8385
| `data` | `array` | | required | `undefined` | Single-element array containing hierarchical object (see `myTreeData` above). <br /> Contains (at least) `name` and `parent` keys. |
8486
| `nodeSvgShape` | `object` | see [Node shapes](#node-shapes) | | `{shape: 'circle', shapeProps: r: 10}` | Sets a specific SVG shape element + shapeProps to be used for each node. |
87+
| `nodeLabelComponent` | `object` | see [Using foreignObjects](#using-foreignobjects) | | `null` | Allows using a React component as a node label; requires `allowForeignObjects` to be set. |
8588
| `onClick` | `func` | | | `undefined` | Callback function to be called when a node is clicked. <br /><br /> The clicked node's data object is passed to the callback function. |
8689
| `onMouseOver` | `func` | | | `undefined` | Callback function to be called when mouse enters the space belonging to a node. <br /><br /> The node's data object is passed to the callback. |
8790
| `onMouseOut` | `func` | | | `undefined` | Callback function to be called when mouse leaves the space belonging to a node. <br /><br /> The node's data object is passed to the callback. |
@@ -98,6 +101,7 @@ class MyComponent extends React.Component {
98101
| `transitionDuration` | `number` | `0..n` | | `500` | Sets the animation duration (in ms) of each expansion/collapse of a tree node. <br /><br /> Set this to `0` to deactivate animations completely. |
99102
| `textLayout` | `object` | `{textAnchor: enum, x: -n..0..n, y: -n..0..n, transform: string}` | | `{textAnchor: "start", x: 10, y: -10, transform: undefined }` | Configures the positioning of each node's text (name & attributes) relative to the node itself.<br/><br/>`textAnchor` enums mirror the [`text-anchor` spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor).<br/><br/>`x` & `y` accept integers denoting `px` values.<br/><br/> `transform` mirrors the [svg `transform` spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform). |
100103
| `styles` | `object` | see [Styling](#styling) | | `Node`/`Link` CSS files | Overrides and/or enhances the tree's default styling. |
104+
| `allowForeignObjects` | `bool` | see [Using foreignObjects](#using-foreignobjects) | | `false` | Allows use of partially supported `<foreignObject />` elements. |
101105
| `circleRadius` (legacy) | `number` | `0..n` | | `undefined` | Sets the radius of each node's `<circle>` element.<br /><br /> **Will be deprecated in v2, please use `nodeSvgShape` instead.** |
102106

103107

@@ -131,6 +135,7 @@ This is to prevent breaking the legacy usage of `circleRadius` + styling via `no
131135

132136
**From v1.5.x onwards, it is therefore recommended to pass all node styling properties through `shapeProps`**.
133137

138+
134139
## Styling
135140
The tree's `styles` prop may be used to override any of the tree's default styling.
136141
The following object shape is expected by `styles`:
@@ -207,5 +212,63 @@ For details regarding the `treeUtil` module, please check the module's [API docs
207212
For examples of each data type that can be parsed with `treeUtil`, please check the [data source examples](docs/examples/data).
208213

209214

215+
## Using foreignObjects
216+
> ⚠️ Requires `allowForeignObjects` prop to be set due to limited browser support: [IE does not currently support `foreignObject` elements](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject#Browser_compatibility).
217+
218+
The SVG spec's `foreignObject` element allows foreign XML content to be rendered into the SVG namespace, unlocking the ability to use regular React components for elements of the tree graph.
219+
220+
### `nodeLabelComponent`
221+
The `nodeLabelComponent` prop provides a way to use a React component for each node's label. It accepts an object with the following signature:
222+
```ts
223+
{
224+
render: ReactElement,
225+
foreignObjectWrapper?: object
226+
}
227+
```
228+
* `render` is the XML React-D3-Tree will use to render each node's label.
229+
* `foreignObjectWrapper` contains a set of attributes that should be passed to the `<foreignObject />` that wraps `nodeLabelComponent`. For possible attributes please check the [spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/foreignObject#Global_attributes).
230+
231+
**Note: `foreignObjectWrapper` will set its width and height attributes to whatever values `nodeSize.x` and `nodeSize.y` return by default.**
232+
To override this behaviour for each attribute, specify `width` and/or `height` properties for your `foreignObjectWrapper`.
233+
234+
**Note:** The ReactElement passed to `render` is cloned with its existing props and **receives an additional `nodeData` object prop, containing information about the current node.**
235+
236+
#### Example
237+
Assuming we have a React component `NodeLabel` and we want to avoid node's label overlapping with the node itself by moving its position along the Y-axis, we could implement `nodeLabelComponent` like so:
238+
```jsx
239+
class NodeLabel extends React.PureComponent {
240+
render() {
241+
const {className, nodeData} = this.props
242+
return (
243+
<div className={className}>
244+
<h2>{nodeData.name}</h2>
245+
{nodeData._children &&
246+
<button>{nodeData._collapsed ? 'Expand' : 'Collapse'}</button>
247+
}
248+
</div>
249+
)
250+
}
251+
}
252+
253+
/* ... */
254+
255+
render() {
256+
return (
257+
<Tree
258+
data={myTreeData}
259+
allowForeignObjects
260+
nodeLabelComponent={{
261+
render: <NodeLabel className='myLabelComponentInSvg' />,
262+
foreignObjectWrapper: {
263+
y: 24
264+
}
265+
}}
266+
/>
267+
)
268+
}
269+
```
270+
271+
272+
210273
## Recipes
211274
* [Auto-centering inside `treeContainer`](https://codesandbox.io/s/vvz51w5n63)

src/Node/ForeignObjectElement.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
export const BASE_MARGIN = 24;
5+
6+
export default class ForeignObjectElement extends React.PureComponent {
7+
render() {
8+
const { nodeData, nodeSize, render, foreignObjectWrapper } = this.props;
9+
return (
10+
<foreignObject
11+
width={nodeSize.x - BASE_MARGIN}
12+
height={nodeSize.y - BASE_MARGIN}
13+
{...foreignObjectWrapper}
14+
>
15+
{React.cloneElement(render, { nodeData })}
16+
</foreignObject>
17+
);
18+
}
19+
}
20+
21+
ForeignObjectElement.defaultProps = {
22+
foreignObjectWrapper: {},
23+
};
24+
25+
ForeignObjectElement.propTypes = {
26+
render: PropTypes.oneOfType([PropTypes.element, PropTypes.node]).isRequired,
27+
nodeData: PropTypes.object.isRequired,
28+
nodeSize: PropTypes.shape({
29+
x: PropTypes.number,
30+
y: PropTypes.number,
31+
}).isRequired,
32+
foreignObjectWrapper: PropTypes.object,
33+
};

src/Node/SvgTextElement.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React from 'react';
2+
import uuid from 'uuid';
3+
import PropTypes from 'prop-types';
4+
5+
export default class SvgTextElement extends React.PureComponent {
6+
render() {
7+
const { name, nodeStyle, textLayout, attributes } = this.props;
8+
return (
9+
<g>
10+
<text
11+
className="nodeNameBase"
12+
style={nodeStyle.name}
13+
textAnchor={textLayout.textAnchor}
14+
x={textLayout.x}
15+
y={textLayout.y}
16+
transform={textLayout.transform}
17+
dy=".35em"
18+
>
19+
{name}
20+
</text>
21+
<text
22+
className="nodeAttributesBase"
23+
y={textLayout.y + 10}
24+
textAnchor={textLayout.textAnchor}
25+
transform={textLayout.transform}
26+
style={nodeStyle.attributes}
27+
>
28+
{attributes &&
29+
Object.keys(attributes).map(labelKey => (
30+
<tspan x={textLayout.x} dy="1.2em" key={uuid.v4()}>
31+
{labelKey}: {attributes[labelKey]}
32+
</tspan>
33+
))}
34+
</text>
35+
</g>
36+
);
37+
}
38+
}
39+
40+
SvgTextElement.defaultProps = {
41+
attributes: undefined,
42+
};
43+
44+
SvgTextElement.propTypes = {
45+
name: PropTypes.string.isRequired,
46+
attributes: PropTypes.object,
47+
textLayout: PropTypes.object.isRequired,
48+
nodeStyle: PropTypes.object.isRequired,
49+
};

src/Node/index.js

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React from 'react';
22
import PropTypes from 'prop-types';
3-
import uuid from 'uuid';
43
import { select } from 'd3';
54

65
import './style.css';
6+
import SvgTextElement from './SvgTextElement';
7+
import ForeignObjectElement from './ForeignObjectElement';
78

89
export default class Node extends React.Component {
910
constructor(props) {
@@ -95,7 +96,14 @@ export default class Node extends React.Component {
9596
}
9697

9798
render() {
98-
const { nodeData, nodeSvgShape, textLayout, styles } = this.props;
99+
const {
100+
nodeData,
101+
nodeSvgShape,
102+
nodeSize,
103+
nodeLabelComponent,
104+
allowForeignObjects,
105+
styles,
106+
} = this.props;
99107
const nodeStyle = nodeData._children ? { ...styles.node } : { ...styles.leafNode };
100108
return (
101109
<g
@@ -120,37 +128,18 @@ export default class Node extends React.Component {
120128
})
121129
)}
122130

123-
<text
124-
className="nodeNameBase"
125-
style={nodeStyle.name}
126-
textAnchor={textLayout.textAnchor}
127-
x={textLayout.x}
128-
y={textLayout.y}
129-
transform={textLayout.transform}
130-
dy=".35em"
131-
>
132-
{this.props.name}
133-
</text>
134-
<text
135-
className="nodeAttributesBase"
136-
y={textLayout.y + 10}
137-
textAnchor={textLayout.textAnchor}
138-
transform={textLayout.transform}
139-
style={nodeStyle.attributes}
140-
>
141-
{this.props.attributes &&
142-
Object.keys(this.props.attributes).map(labelKey => (
143-
<tspan x={textLayout.x} dy="1.2em" key={uuid.v4()}>
144-
{labelKey}: {this.props.attributes[labelKey]}
145-
</tspan>
146-
))}
147-
</text>
131+
{allowForeignObjects && nodeLabelComponent ? (
132+
<ForeignObjectElement nodeData={nodeData} nodeSize={nodeSize} {...nodeLabelComponent} />
133+
) : (
134+
<SvgTextElement {...this.props} nodeStyle={nodeStyle} />
135+
)}
148136
</g>
149137
);
150138
}
151139
}
152140

153141
Node.defaultProps = {
142+
nodeLabelComponent: null,
154143
attributes: undefined,
155144
circleRadius: undefined,
156145
styles: {
@@ -170,6 +159,8 @@ Node.defaultProps = {
170159
Node.propTypes = {
171160
nodeData: PropTypes.object.isRequired,
172161
nodeSvgShape: PropTypes.object.isRequired,
162+
nodeLabelComponent: PropTypes.object,
163+
nodeSize: PropTypes.object.isRequired,
173164
orientation: PropTypes.oneOf(['horizontal', 'vertical']).isRequired,
174165
transitionDuration: PropTypes.number.isRequired,
175166
onClick: PropTypes.func.isRequired,
@@ -179,6 +170,7 @@ Node.propTypes = {
179170
attributes: PropTypes.object,
180171
textLayout: PropTypes.object.isRequired,
181172
subscriptions: PropTypes.object.isRequired, // eslint-disable-line react/no-unused-prop-types
173+
allowForeignObjects: PropTypes.bool.isRequired,
182174
circleRadius: PropTypes.number,
183175
styles: PropTypes.object,
184176
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from 'react';
2+
import { shallow } from 'enzyme';
3+
import ForeignObjectElement, { BASE_MARGIN } from '../ForeignObjectElement';
4+
5+
const TestComponent = () => <div />;
6+
7+
const mockNodeData = {
8+
id: 'abc123',
9+
name: 'mockNode',
10+
depth: 3,
11+
x: 111,
12+
y: 222,
13+
parent: {
14+
x: 999,
15+
y: 888,
16+
},
17+
};
18+
19+
const mockProps = {
20+
nodeData: mockNodeData,
21+
nodeSize: {
22+
x: 123,
23+
y: 321,
24+
},
25+
render: <TestComponent />,
26+
};
27+
28+
describe('<ForeignObjectElement />', () => {
29+
const setup = (addedProps, renderer = shallow) => {
30+
const props = {
31+
...mockProps,
32+
...addedProps,
33+
};
34+
return renderer(<ForeignObjectElement {...props} />);
35+
};
36+
37+
describe('props.render', () => {
38+
it('clones the NodeLabelComponent defined in `props.render` as expected', () => {
39+
const renderedComponent = setup();
40+
expect(renderedComponent.find(TestComponent).length).toBe(1);
41+
});
42+
43+
it('passes `props.nodeData` into the rendered NodeLabelComponent', () => {
44+
const renderedComponent = setup();
45+
expect(renderedComponent.find(TestComponent).prop('nodeData')).toBeDefined();
46+
});
47+
});
48+
49+
describe('foreignObject Wrapper', () => {
50+
it('sets width/height to nodeSize.x/nodeSize.y with a margin by default', () => {
51+
const renderedComponent = setup();
52+
expect(renderedComponent.find('foreignObject').prop('width')).toBe(
53+
mockProps.nodeSize.x - BASE_MARGIN,
54+
);
55+
expect(renderedComponent.find('foreignObject').prop('height')).toBe(
56+
mockProps.nodeSize.y - BASE_MARGIN,
57+
);
58+
});
59+
60+
it('accepts any props defined in `props.foreignObjectWrapper`', () => {
61+
const foreignObjectWrapper = {
62+
width: 999,
63+
height: 111,
64+
x: 12,
65+
};
66+
const renderedComponent = setup({ foreignObjectWrapper });
67+
expect(renderedComponent.find('foreignObject').prop('width')).toBe(
68+
foreignObjectWrapper.width,
69+
);
70+
expect(renderedComponent.find('foreignObject').prop('height')).toBe(
71+
foreignObjectWrapper.height,
72+
);
73+
expect(renderedComponent.find('foreignObject').prop('x')).toBe(foreignObjectWrapper.x);
74+
});
75+
});
76+
});

src/Node/tests/SvgTextElement.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import React from 'react';
2+
import { shallow } from 'enzyme';
3+
4+
import SvgTextElement from '../SvgTextElement';
5+
6+
describe('<SvgTextElement />', () => {
7+
const mockProps = {
8+
name: 'svgNodeName',
9+
textLayout: {},
10+
nodeStyle: {},
11+
attributes: {
12+
'attribute 1': 'value 1',
13+
'attribute 2': 'value 2',
14+
},
15+
};
16+
17+
it('maps each `props.attributes` to a <tspan> element', () => {
18+
const fixture = { keyA: 'valA', keyB: 'valB' };
19+
const renderedComponent = shallow(<SvgTextElement {...mockProps} attributes={fixture} />);
20+
const textNode = renderedComponent
21+
.find('text')
22+
.findWhere(n => n.prop('className') === 'nodeAttributesBase');
23+
24+
expect(textNode.findWhere(n => n.text() === `keyA: ${fixture.keyA}`).length).toBe(1);
25+
expect(textNode.findWhere(n => n.text() === `keyB: ${fixture.keyB}`).length).toBe(1);
26+
});
27+
28+
it('applies the `textLayout` prop to the node name & attributes', () => {
29+
const fixture = {
30+
textAnchor: 'test',
31+
x: 999,
32+
y: 111,
33+
};
34+
const renderedComponent = shallow(<SvgTextElement {...mockProps} textLayout={fixture} />);
35+
const nodeName = renderedComponent
36+
.find('text')
37+
.findWhere(n => n.prop('className') === 'nodeNameBase');
38+
const nodeAttribute = renderedComponent.find('tspan').first();
39+
expect(nodeName.props()).toEqual(expect.objectContaining(fixture));
40+
expect(nodeAttribute.prop('x')).toBe(fixture.x);
41+
});
42+
});

0 commit comments

Comments
 (0)