orgchart
Version:
Simple and direct organization chart(tree-like hierarchy) library based on pure DOM.
1,427 lines (1,194 loc) • 48.2 kB
JavaScript
const chai = require('chai');
const sinon = require('sinon');
const sinonChai = require('sinon-chai').default;
const should = chai.should();
chai.use(sinonChai);
require('jsdom-global')();
process.env.ORGCHART_TEST = '1';
const OrgChart = require('../../src/js/orgchart');
const getState = OrgChart.__testing__.getState;
const setState = OrgChart.__testing__.setState;
function query(selector, root) {
return (root || document).querySelector(selector);
}
function queryAll(selector, root) {
return Array.from((root || document).querySelectorAll(selector));
}
function createElementFromHtml(html) {
const template = document.createElement('template');
template.innerHTML = html.trim();
return template.content.firstElementChild;
}
describe('orgchart -- init and builder unit tests', function () {
let $container;
let oc;
const ds = {
id: 'n1',
name: 'Lao Lao',
title: 'general manager',
children: [
{ id: 'n2', name: 'Bo Miao', title: 'department manager' },
{
id: 'n3',
name: 'Su Miao',
title: 'department manager',
children: [
{ id: 'n5', name: 'Tie Hua', title: 'senior engineer' },
{ id: 'n6', name: 'Hei Hei', title: 'senior engineer' }
]
},
{ id: 'n4', name: 'Hong Miao', title: 'department manager' }
]
};
beforeEach(function () {
document.body.innerHTML = '<div id="chart-host"><div id="chart-container"></div></div>';
$container = document.getElementById('chart-container');
oc = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
nodeContent: 'title'
});
});
afterEach(function () {
document.body.innerHTML = '';
oc = null;
$container = null;
});
describe('constructor() and init()', function () {
it('supports invoking OrgChart without new', function () {
const chart = OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds))
});
chart.should.be.instanceOf(OrgChart);
should.exist(chart.chart);
chart.chartContainer.should.equal($container);
});
it('accepts the container alias in the options object', function () {
const chart = new OrgChart({
container: '#chart-container',
data: JSON.parse(JSON.stringify(ds))
});
chart.chartContainer.should.equal($container);
should.exist(chart.chart);
});
it('does not initialize when the container cannot be resolved', function () {
const initSpy = sinon.spy(OrgChart.prototype, 'init');
const chart = new OrgChart({
chartContainer: '#missing-container',
data: JSON.parse(JSON.stringify(ds))
});
should.equal(chart.chartContainer, null);
should.equal(chart.chart, null);
initSpy.should.not.have.been.called;
initSpy.restore();
});
it('stores merged options on the chart state during init', function () {
let chart;
$container.innerHTML = '';
chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
chartClass: 'alpha',
direction: 'l2r',
zoom: true,
visibleLevel: 3
});
getState(chart.chart, 'options').chartClass.should.equal('alpha');
getState(chart.chart, 'options').direction.should.equal('l2r');
getState(chart.chart, 'options').zoom.should.equal(true);
getState(chart.chart, 'options').visibleLevel.should.equal(3);
getState(chart.chart, 'options').nodeId.should.equal('id');
});
it('replaces the previous chart element when init() runs again', function () {
const oldChart = oc.chart;
oc.init({ data: JSON.parse(JSON.stringify(ds)), chartClass: 'reloaded' });
should.exist(oc.chart);
oc.chart.should.not.equal(oldChart);
oldChart.isConnected.should.equal(false);
oc.chart.classList.contains('reloaded').should.equal(true);
$container.querySelectorAll('.orgchart').length.should.equal(1);
});
it('applies chartClass and non-default direction classes during init', function () {
let chart;
$container.innerHTML = '';
chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
chartClass: 'custom-chart',
direction: 'b2t'
});
chart.chart.classList.contains('custom-chart').should.equal(true);
chart.chart.classList.contains('b2t').should.equal(true);
});
it('uses the root nodes container directly for grouped family-tree arrays', function () {
let chart;
$container.innerHTML = '';
chart = new OrgChart({
chartContainer: '#chart-container',
data: [[
{ id: 'n1', name: 'Lao Lao', relationship: '001' },
{ id: 'n2', name: 'Bo Miao', relationship: '001' }
]]
});
queryAll(':scope > ul.nodes > li.hierarchy', chart.chart).length.should.equal(1);
query(':scope > ul.nodes > li.hierarchy.spouse.couple', chart.chart).should.not.equal(null);
query('#n1', chart.chart).should.not.equal(null);
query('#n2', chart.chart).should.not.equal(null);
});
it('builds a chart from a UL datasource during init()', function () {
let chart;
let ulElement;
$container.innerHTML = '';
ulElement = createElementFromHtml(
'<ul id="org-data">' +
'<li data-id="n1" data-title="chief">Lao Lao' +
'<ul>' +
'<li data-id="n2" data-title="manager">Bo Miao</li>' +
'<li data-id="n3" data-title="manager">Su Miao</li>' +
'</ul>' +
'</li>' +
'</ul>'
);
document.body.appendChild(ulElement);
chart = new OrgChart({
chartContainer: '#chart-container',
data: ulElement,
nodeContent: 'title'
});
query('#n1', chart.chart).should.not.equal(null);
query('#n2', chart.chart).should.not.equal(null);
query('#n3', chart.chart).should.not.equal(null);
query('.content', query('#n1', chart.chart)).textContent.should.equal('chief');
ulElement.remove();
});
it('routes plain object data through attachRel() before building', function () {
const attachRelSpy = sinon.spy(oc, 'attachRel');
oc.init({
data: {
id: 'r1',
name: 'Root',
children: [{ id: 'c1', name: 'Child' }]
}
});
attachRelSpy.should.have.been.called;
attachRelSpy.firstCall.args[1].should.equal('00');
attachRelSpy.restore();
});
it('keeps explicit relationship data untouched during init()', function () {
const attachRelSpy = sinon.spy(oc, 'attachRel');
oc.init({
data: {
id: 'r1',
name: 'Root',
relationship: '001',
children: [{ id: 'c1', name: 'Child', relationship: '100' }]
}
});
attachRelSpy.should.not.have.been.called;
attachRelSpy.restore();
query('#r1', oc.chart).should.not.equal(null);
query('#c1', oc.chart).should.not.equal(null);
});
it('calls attachExportButton() only when exportButton is enabled', function () {
const attachSpy = sinon.spy(oc, 'attachExportButton');
oc.init({ data: JSON.parse(JSON.stringify(ds)), exportButton: false });
attachSpy.should.not.have.been.called;
oc.init({ data: JSON.parse(JSON.stringify(ds)), exportButton: true });
attachSpy.should.have.been.calledOnce;
attachSpy.restore();
});
it('binds pan and zoom during init() when the options are enabled', function () {
const panSpy = sinon.spy(oc, 'bindPan');
const zoomSpy = sinon.spy(oc, 'bindZoom');
oc.init({ data: JSON.parse(JSON.stringify(ds)), pan: true, zoom: true });
panSpy.should.have.been.calledOnce;
zoomSpy.should.have.been.calledOnce;
panSpy.restore();
zoomSpy.restore();
});
});
describe('setOptions()', function () {
it('binds pan when called with the string form and true', function () {
const bindSpy = sinon.spy(oc, 'bindPan');
oc.setOptions('pan', true).should.equal(oc);
bindSpy.should.have.been.calledOnce;
bindSpy.restore();
});
it('unbinds pan when called with the string form and false', function () {
const unbindSpy = sinon.spy(oc, 'unbindPan');
oc.setOptions('pan', false).should.equal(oc);
unbindSpy.should.have.been.calledOnce;
unbindSpy.restore();
});
it('binds zoom when called with the string form and true', function () {
const bindSpy = sinon.spy(oc, 'bindZoom');
oc.setOptions('zoom', true).should.equal(oc);
bindSpy.should.have.been.calledOnce;
bindSpy.restore();
});
it('unbinds zoom when called with the string form and false', function () {
const unbindSpy = sinon.spy(oc, 'unbindZoom');
oc.setOptions('zoom', false).should.equal(oc);
unbindSpy.should.have.been.calledOnce;
unbindSpy.restore();
});
it('reinitializes when called with an object that contains data', function () {
const initSpy = sinon.spy(oc, 'init');
oc.setOptions({ data: { id: 'x1', name: 'Reloaded Root' } }).should.equal(oc);
initSpy.should.have.been.calledOnce;
initSpy.firstCall.args[0].should.have.property('data');
initSpy.firstCall.args[0].data.id.should.equal('x1');
initSpy.firstCall.args[0].data.name.should.equal('Reloaded Root');
initSpy.firstCall.args[0].data.relationship.should.equal('000');
initSpy.firstCall.args[0].data.level.should.equal(1);
initSpy.restore();
});
it('toggles pan and zoom independently when called with an object', function () {
const bindPanSpy = sinon.spy(oc, 'bindPan');
const unbindPanSpy = sinon.spy(oc, 'unbindPan');
const bindZoomSpy = sinon.spy(oc, 'bindZoom');
const unbindZoomSpy = sinon.spy(oc, 'unbindZoom');
oc.setOptions({ pan: true, zoom: false }).should.equal(oc);
bindPanSpy.should.have.been.calledOnce;
unbindZoomSpy.should.have.been.calledOnce;
unbindPanSpy.should.have.been.calledOnce;
bindZoomSpy.should.not.have.been.called;
bindPanSpy.restore();
unbindPanSpy.restore();
bindZoomSpy.restore();
unbindZoomSpy.restore();
});
it('ignores unrelated option names in string form', function () {
const bindPanSpy = sinon.spy(oc, 'bindPan');
const bindZoomSpy = sinon.spy(oc, 'bindZoom');
oc.setOptions('visibleLevel', 2).should.equal(oc);
bindPanSpy.should.not.have.been.called;
bindZoomSpy.should.not.have.been.called;
bindPanSpy.restore();
bindZoomSpy.restore();
});
});
describe('attachRel()', function () {
it('assigns root and child relationship flags recursively', function () {
const tree = {
id: 'n1',
name: 'Root',
children: [
{ id: 'n2', name: 'Left' },
{ id: 'n3', name: 'Right', children: [{ id: 'n4', name: 'Leaf' }] }
]
};
oc.attachRel(tree, '00');
tree.relationship.should.equal('001');
tree.children[0].relationship.should.equal('110');
tree.children[1].relationship.should.equal('111');
tree.children[1].children[0].relationship.should.equal('100');
});
it('marks compact nodes when the compact option predicate returns true', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
compact: function (nodeData) {
return nodeData.id === 'n3';
}
});
const tree = {
id: 'n1',
name: 'Root',
children: [
{ id: 'n2', name: 'Left' },
{
id: 'n3',
name: 'Right',
children: [{ id: 'n4', name: 'Leaf' }]
}
]
};
chart.attachRel(tree, '00');
should.equal(tree.compact, undefined);
tree.children[1].compact.should.equal(true);
tree.children[1].children[0].associatedCompact.should.equal(true);
});
it('marks all descendants vertical under a hybrid node', function () {
const tree = {
id: 'n1',
name: 'Root',
hybrid: true,
children: [
{
id: 'n2',
name: 'Child',
children: [{ id: 'n3', name: 'Grandchild' }]
}
]
};
oc.attachRel(tree, '00');
tree.children[0].vertical.should.equal(true);
tree.children[0].children[0].vertical.should.equal(true);
});
it('marks compact descendants and compact leaves differently', function () {
const tree = {
id: 'n1',
name: 'Root',
compact: true,
children: [
{
id: 'n2',
name: 'Has Children',
children: [{ id: 'n4', name: 'Leaf' }]
},
{ id: 'n3', name: 'Leaf' }
]
};
oc.attachRel(tree, '00');
tree.children[0].compact.should.equal(true);
tree.children[1].associatedCompact.should.equal(true);
tree.children[0].children[0].associatedCompact.should.equal(true);
});
it('returns the same data object after mutating relationships', function () {
const tree = { id: 'n1', name: 'Root' };
oc.attachRel(tree, '00').should.equal(tree);
tree.relationship.should.equal('000');
});
});
describe('createNode()', function () {
it('renders the standard title, content, and edges from relationship flags', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
title: 'architect',
relationship: '111',
level: 1
});
nodeEl.classList.contains('node').should.equal(true);
nodeEl.id.should.equal('n11');
query('.title', nodeEl).textContent.trim().should.equal('Li Xin');
query('.content', nodeEl).textContent.should.equal('architect');
should.exist(query('.topEdge', nodeEl));
should.exist(query('.leftEdge', nodeEl));
should.exist(query('.rightEdge', nodeEl));
should.exist(query('.bottomEdge', nodeEl));
should.exist(query('.parentNodeSymbol', nodeEl));
});
it('propagates parentId to direct children when a node id exists', function () {
const data = {
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0,
children: [{ id: 'n12', name: 'Child' }]
};
oc.createNode(data);
data.children[0].parentId.should.equal('n11');
});
it('stores nodeData on the created element without the children array', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0,
children: [{ id: 'n12', name: 'Child' }]
});
getState(nodeEl, 'nodeData').id.should.equal('n11');
getState(nodeEl, 'nodeData').name.should.equal('Li Xin');
should.equal(getState(nodeEl, 'nodeData').children, undefined);
nodeEl.__ocNodeData.id.should.equal('n11');
});
it('adds the slide-up class when the node level exceeds visibleLevel', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
visibleLevel: 1
});
const nodeEl = chart.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '000',
level: 2
});
nodeEl.classList.contains('slide-up').should.equal(true);
});
it('renders custom className and outsider classes', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '000',
level: 0,
className: 'custom-node',
outsider: true
});
nodeEl.classList.contains('custom-node').should.equal(true);
nodeEl.classList.contains('outsider').should.equal(true);
});
it('renders custom nodeTemplate output instead of the default title/content markup', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
nodeTemplate: function (data) {
return '<section class="custom-template">' + data.name + '</section>';
}
});
const nodeEl = chart.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0
});
should.exist(query('.custom-template', nodeEl));
should.equal(query('.title', nodeEl), null);
should.equal(query('.content', nodeEl), null);
});
it('omits the content block when nodeContent is undefined', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds))
});
const nodeEl = chart.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0,
title: 'architect'
});
should.equal(query('.content', nodeEl), null);
});
it('renders a vertical toggle button at or beyond verticalLevel', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
verticalLevel: 2
});
const nodeEl = chart.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 2
});
should.exist(query('.toggleBtn', nodeEl));
should.equal(query('.bottomEdge', nodeEl), null);
});
it('renders a toggle button for explicitly vertical nodes', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0,
vertical: true
});
should.exist(query('.toggleBtn', nodeEl));
});
it('renders a bottom edge for hybrid nodes with children', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0,
hybrid: true
});
should.exist(query('.bottomEdge', nodeEl));
should.equal(query('.toggleBtn', nodeEl), null);
});
it('renders compact mode controls and grid columns for compact nodes', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0,
compact: true,
children: [
{ id: 'n12', name: 'A' },
{ id: 'n13', name: 'B' },
{ id: 'n14', name: 'C' }
]
});
should.exist(query('.backToCompactSymbol', nodeEl));
should.exist(query('.backToLooseSymbol', nodeEl));
nodeEl.style.gridTemplateColumns.should.equal('repeat(2, auto)');
});
it('suppresses default directional edges for associatedCompact nodes', function () {
const nodeEl = oc.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '111',
level: 1,
associatedCompact: true
});
should.equal(query('.topEdge', nodeEl), null);
should.equal(query('.leftEdge', nodeEl), null);
should.equal(query('.rightEdge', nodeEl), null);
should.equal(query('.bottomEdge', nodeEl), null);
});
it('marks a created node draggable and initializes touch drag state when draggable is enabled', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
draggable: true
});
const bindSpy = sinon.spy(chart, 'bindDragDrop');
const nodeEl = chart.createNode({
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0
});
nodeEl.getAttribute('draggable').should.equal('true');
bindSpy.should.have.been.calledOnce;
bindSpy.firstCall.args[0].should.equal(nodeEl);
chart.touchHandled.should.equal(false);
chart.touchMoved.should.equal(false);
should.equal(chart.touchTargetNode, null);
bindSpy.restore();
});
it('calls the createNode callback with the native node element and data', function () {
const createNodeSpy = sinon.spy();
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
createNode: createNodeSpy
});
const nodeData = {
id: 'n11',
name: 'Li Xin',
relationship: '001',
level: 0
};
createNodeSpy.resetHistory();
const nodeEl = chart.createNode(nodeData);
createNodeSpy.should.have.been.calledOnce;
createNodeSpy.firstCall.args[0].should.equal(nodeEl);
createNodeSpy.firstCall.args[1].should.equal(nodeData);
});
});
describe('buildInferiorNodes()', function () {
it('creates a hidden vertical nodes layer at the vertical cutoff', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
verticalLevel: 2,
visibleLevel: 1
});
const hierarchyEl = document.createElement('li');
const nodeEl = chart.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 1 });
hierarchyEl.className = 'hierarchy';
hierarchyEl.appendChild(nodeEl);
chart.buildInferiorNodes(hierarchyEl, nodeEl, {
collapsed: true,
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
}, 1);
should.exist(query(':scope > .nodes.vertical.hidden', hierarchyEl));
should.exist(query('#n12', hierarchyEl));
});
it('creates a vertical layer for hybrid nodes outside an existing vertical context', function () {
const hierarchyEl = document.createElement('li');
const nodeEl = oc.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 0 });
hierarchyEl.className = 'hierarchy';
hierarchyEl.appendChild(nodeEl);
oc.buildInferiorNodes(hierarchyEl, nodeEl, {
hybrid: true,
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
}, 0);
should.exist(query(':scope > .nodes.vertical', hierarchyEl));
should.exist(query('#n12', hierarchyEl));
});
it('does not add a second vertical class when already inside a vertical branch', function () {
const verticalWrapper = document.createElement('ul');
const hierarchyEl = document.createElement('li');
const nodeEl = oc.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 1 });
let nodesEl;
verticalWrapper.className = 'nodes vertical';
hierarchyEl.className = 'hierarchy';
hierarchyEl.appendChild(nodeEl);
verticalWrapper.appendChild(hierarchyEl);
$container.appendChild(verticalWrapper);
oc.buildInferiorNodes(hierarchyEl, nodeEl, {
hybrid: true,
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
}, 1);
nodesEl = query(':scope > .nodes', hierarchyEl);
nodesEl.classList.contains('vertical').should.equal(false);
});
it('marks the hierarchy as children-collapsed for hidden horizontal children', function () {
const chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
visibleLevel: 0
});
const hierarchyEl = document.createElement('li');
const nodeEl = chart.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 0 });
hierarchyEl.className = 'hierarchy';
hierarchyEl.appendChild(nodeEl);
chart.buildInferiorNodes(hierarchyEl, nodeEl, {
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
}, 0);
hierarchyEl.classList.contains('isChildrenCollapsed').should.equal(true);
should.exist(query(':scope > .nodes.hidden', hierarchyEl));
});
it('keeps compact descendants inside the compact node element', function () {
const hierarchyEl = document.createElement('li');
const nodeEl = oc.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 0 });
hierarchyEl.className = 'hierarchy';
hierarchyEl.appendChild(nodeEl);
oc.buildInferiorNodes(hierarchyEl, nodeEl, {
compact: true,
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
}, 0);
nodeEl.classList.contains('compact').should.equal(true);
should.equal(query('.nodes', hierarchyEl), null);
should.exist(query('#n12', nodeEl));
});
it('handles grouped family-tree children arrays', function () {
const hierarchyEl = document.createElement('li');
const nodeEl = oc.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 0 });
hierarchyEl.className = 'hierarchy';
hierarchyEl.appendChild(nodeEl);
oc.buildInferiorNodes(hierarchyEl, nodeEl, {
children: [[
{ id: 'n12', name: 'Spouse A', relationship: '001' },
{
id: 'n13',
name: 'Spouse B',
relationship: '001',
outsider: true,
children: [[{ id: 'n14', name: 'Child', relationship: '000' }]]
}
]]
}, 0);
should.exist(query('.couple', hierarchyEl));
should.exist(query('#n12', hierarchyEl));
should.exist(query('#n13', hierarchyEl));
should.exist(query('#n14', hierarchyEl));
});
it('returns early when the hierarchy element cannot be resolved', function () {
should.equal(oc.buildInferiorNodes(null, null, { children: [] }, 0), undefined);
});
});
describe('buildHierarchy()', function () {
it('creates a single node and its descendants for plain object data', function () {
const hierarchyEl = document.createElement('li');
hierarchyEl.className = 'hierarchy';
oc.buildHierarchy(hierarchyEl, {
id: 'n11',
name: 'Parent',
relationship: '001',
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
});
should.exist(query('#n11', hierarchyEl));
should.exist(query('#n12', hierarchyEl));
should.exist(query('.nodes', hierarchyEl));
});
it('derives the level from ancestor nodes layers when level is absent', function () {
const chartEl = document.createElement('div');
const outerNodesEl = document.createElement('ul');
const outerHierarchyEl = document.createElement('li');
const innerNodesEl = document.createElement('ul');
const hierarchyEl = document.createElement('li');
const data = { id: 'n11', name: 'Parent', relationship: '000' };
chartEl.className = 'orgchart';
outerNodesEl.className = 'nodes';
outerHierarchyEl.className = 'hierarchy';
innerNodesEl.className = 'nodes';
hierarchyEl.className = 'hierarchy';
outerHierarchyEl.appendChild(innerNodesEl);
innerNodesEl.appendChild(hierarchyEl);
outerNodesEl.appendChild(outerHierarchyEl);
chartEl.appendChild(outerNodesEl);
$container.appendChild(chartEl);
oc.buildHierarchy(hierarchyEl, data);
data.level.should.equal(2);
should.exist(query('#n11', hierarchyEl));
});
it('preserves an explicit data.level value', function () {
const hierarchyEl = document.createElement('li');
const data = { id: 'n11', name: 'Parent', relationship: '000', level: 7 };
hierarchyEl.className = 'hierarchy';
oc.buildHierarchy(hierarchyEl, data);
data.level.should.equal(7);
});
it('propagates a derived level into grouped family-tree arrays', function () {
const hierarchyEl = document.createElement('li');
const familyData = [[
{ id: 'n11', name: 'Spouse A', relationship: '001' },
{ id: 'n12', name: 'Spouse B', relationship: '001', outsider: true }
]];
hierarchyEl.className = 'hierarchy';
oc.buildHierarchy(hierarchyEl, familyData);
familyData[0][0].level.should.equal(0);
familyData[0][1].level.should.equal(0);
});
it('creates spouse, couple, and insider wrappers for family trees', function () {
const hierarchyEl = document.createElement('li');
hierarchyEl.className = 'hierarchy';
oc.buildHierarchy(hierarchyEl, [[
{ id: 'n11', name: 'Spouse A', relationship: '001', outsider: false },
{ id: 'n12', name: 'Spouse B', relationship: '001', outsider: true },
{ id: 'n13', name: 'Spouse C', relationship: '001', outsider: true }
]]);
queryAll(':scope > .spouse', hierarchyEl).length.should.equal(3);
queryAll(':scope > .insider', hierarchyEl).length.should.equal(1);
should.exist(query('#n11', hierarchyEl));
should.exist(query('#n12', hierarchyEl));
should.exist(query('#n13', hierarchyEl));
});
it('inserts the second spouse after the first one for a couple wrapper', function () {
const hierarchyEl = document.createElement('li');
hierarchyEl.className = 'hierarchy';
oc.buildHierarchy(hierarchyEl, [[
{ id: 'n11', name: 'Spouse A', relationship: '001', outsider: false },
{ id: 'n12', name: 'Spouse B', relationship: '001', outsider: true }
]]);
queryAll('.node', hierarchyEl).map(function (nodeEl) {
return nodeEl.id;
}).should.deep.equal(['n11', 'n12']);
});
it('creates descendant nodes when data only contains children metadata', function () {
const hierarchyEl = document.createElement('li');
hierarchyEl.className = 'hierarchy';
oc.buildHierarchy(hierarchyEl, {
children: [{ id: 'n12', name: 'Child', relationship: '000' }]
});
should.equal(query('.node', hierarchyEl).id, 'n12');
should.exist(query('#n12', hierarchyEl));
});
it('returns early when the hierarchy element cannot be resolved', function () {
should.equal(oc.buildHierarchy(null, { id: 'n11', name: 'Parent' }), undefined);
});
});
describe('buildChildNode()', function () {
it('returns early when the append target is missing', function () {
should.equal(oc.buildChildNode(null, [{ id: 'n11', name: 'Child' }]), undefined);
});
it('delegates to buildHierarchy() when the parent has no child nodes container', function () {
const hierarchyEl = document.createElement('li');
const buildSpy = sinon.spy(oc, 'buildHierarchy');
hierarchyEl.className = 'hierarchy';
oc.buildChildNode(hierarchyEl, [{ id: 'n11', name: 'Child' }]);
buildSpy.should.have.callCount(2);
buildSpy.firstCall.args[0].should.equal(hierarchyEl);
buildSpy.firstCall.args[1].should.have.property('children');
buildSpy.secondCall.args[1].id.should.equal('n11');
buildSpy.restore();
});
it('appends new child hierarchies into an existing child nodes container', function () {
const hierarchyEl = document.createElement('li');
const nodeEl = oc.createNode({ id: 'n11', name: 'Parent', relationship: '001', level: 0 });
const nodesEl = document.createElement('ul');
hierarchyEl.className = 'hierarchy';
nodesEl.className = 'nodes';
hierarchyEl.appendChild(nodeEl);
hierarchyEl.appendChild(nodesEl);
oc.buildChildNode(hierarchyEl, [
{ id: 'n12', name: 'Child A', relationship: '000' },
{ id: 'n13', name: 'Child B', relationship: '000' }
]);
queryAll(':scope > .hierarchy', nodesEl).length.should.equal(2);
should.exist(query('#n12', nodesEl));
should.exist(query('#n13', nodesEl));
});
});
describe('buildParentNode()', function () {
it('returns early when the current root cannot be resolved', function () {
should.equal(oc.buildParentNode(null, { id: 'n0', name: 'Ancestor' }), undefined);
});
it('wraps the current root list with a new ancestor hierarchy', function () {
const currentRootEl = query('#n1', oc.chart);
oc.buildParentNode(currentRootEl, { id: 'n0', name: 'Ancestor' });
should.exist(query('#n0', oc.chart));
query('#n1', oc.chart).closest('.nodes').previousElementSibling.id.should.equal('n0');
query(':scope > .nodes > .hierarchy > #n0', oc.chart).should.not.equal(null);
});
it('defaults the new parent relationship to 001 when absent', function () {
const currentRootEl = query('#n1', oc.chart);
oc.buildParentNode(currentRootEl, { id: 'n0', name: 'Ancestor' });
getState(query('#n0', oc.chart), 'nodeData').relationship.should.equal('001');
});
});
describe('attachExportButton()', function () {
it('returns early when the chart container has no parent element', function () {
const detachedContainer = document.createElement('div');
const chart = new OrgChart({
data: JSON.parse(JSON.stringify(ds))
});
chart.chartContainer = detachedContainer;
chart.options = Object.assign({}, chart.defaultOptions);
should.equal(chart.attachExportButton(), undefined);
should.equal(document.querySelector('.oc-export-btn'), null);
});
it('uses exportButtonName as the rendered button text', function () {
let chart;
$container.innerHTML = '';
chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
exportButton: true,
exportButtonName: 'Download Chart'
});
document.querySelector('.oc-export-btn').textContent.should.equal('Download Chart');
document.querySelector('.oc-export-btn').remove();
});
});
describe('export helpers', function () {
it('exportPDF() uses the jsPDF constructor for wide canvases', function () {
const originalJsPDF = global.jsPDF;
const originalWindowJsPDF = window.jsPDF;
const addImageSpy = sinon.spy();
const saveSpy = sinon.spy();
const jsPDFSpy = sinon.spy(function () {
this.addImage = addImageSpy;
this.save = saveSpy;
});
const canvas = {
width: 500,
height: 200,
toDataURL: function () {
return 'data:image/png;base64,fake';
}
};
global.jsPDF = jsPDFSpy;
window.jsPDF = jsPDFSpy;
oc.exportPDF(canvas, 'WideChart');
jsPDFSpy.should.have.been.calledOnce;
jsPDFSpy.firstCall.args[0].orientation.should.equal('landscape');
jsPDFSpy.firstCall.args[0].format.should.deep.equal([500, 200]);
addImageSpy.should.have.been.calledOnce;
addImageSpy.should.have.been.calledWith('data:image/png;base64,fake', 'png', 0, 0, 500, 200);
saveSpy.should.have.been.calledOnce;
saveSpy.firstCall.args[0].should.equal('WideChart.pdf');
global.jsPDF = originalJsPDF;
window.jsPDF = originalWindowJsPDF;
});
it('exportPDF() uses portrait for tall canvases', function () {
const originalJsPDF = global.jsPDF;
const originalWindowJsPDF = window.jsPDF;
const addImageSpy = sinon.spy();
const saveSpy = sinon.spy();
const jsPDFSpy = sinon.spy(function () {
this.addImage = addImageSpy;
this.save = saveSpy;
});
const canvas = {
width: 200,
height: 500,
toDataURL: function () {
return 'data:image/png;base64,fake';
}
};
global.jsPDF = jsPDFSpy;
window.jsPDF = jsPDFSpy;
oc.exportPDF(canvas, 'TallChart');
jsPDFSpy.should.have.been.calledOnce;
jsPDFSpy.firstCall.args[0].orientation.should.equal('portrait');
jsPDFSpy.firstCall.args[0].format.should.deep.equal([500, 200]);
addImageSpy.should.have.been.calledOnce;
addImageSpy.should.have.been.calledWith('data:image/png;base64,fake', 'png', 0, 0, 200, 500);
saveSpy.should.have.been.calledOnce;
global.jsPDF = originalJsPDF;
window.jsPDF = originalWindowJsPDF;
});
it('exportPDF() preserves logical dimensions for high-resolution canvases', function () {
const originalJsPDF = global.jsPDF;
const originalWindowJsPDF = window.jsPDF;
const addImageSpy = sinon.spy();
const jsPDFSpy = sinon.spy(function () {
this.addImage = addImageSpy;
this.save = sinon.spy();
});
const canvas = {
width: 1000,
height: 400,
toDataURL: function () {
return 'data:image/png;base64,fake';
}
};
global.jsPDF = jsPDFSpy;
window.jsPDF = jsPDFSpy;
oc.exportPDF(canvas, 'HighResolutionChart', 2);
jsPDFSpy.firstCall.args[0].format.should.deep.equal([500, 200]);
addImageSpy.should.have.been.calledWith('data:image/png;base64,fake', 'png', 0, 0, 500, 200);
global.jsPDF = originalJsPDF;
window.jsPDF = originalWindowJsPDF;
});
it('exportPNG() returns early when the chart container is missing', function () {
const chart = new OrgChart({ data: JSON.parse(JSON.stringify(ds)) });
chart.chartContainer = null;
should.equal(chart.exportPNG({ toDataURL: function () { return ''; } }, 'Missing'), undefined);
});
it('exportPNG() uses msSaveBlob on Edge and IE code paths', function () {
const originalNavigator = window.navigator;
const canvas = {
msToBlob: sinon.stub().returns('blob-data')
};
const msSaveBlobSpy = sinon.spy();
Object.defineProperty(window, 'navigator', {
configurable: true,
value: {
appName: 'Microsoft Internet Explorer',
appVersion: '11.0',
msSaveBlob: msSaveBlobSpy
}
});
oc.exportPNG(canvas, 'LegacyExport');
canvas.msToBlob.should.have.been.calledOnce;
msSaveBlobSpy.should.have.been.calledOnce;
msSaveBlobSpy.firstCall.args[1].should.equal('LegacyExport.png');
Object.defineProperty(window, 'navigator', {
configurable: true,
value: originalNavigator
});
});
it('exportPNG() creates and reuses a chartClass-scoped download anchor', function () {
const originalNavigator = window.navigator;
const originalClick = window.HTMLAnchorElement.prototype.click;
const originalSidebar = window.sidebar;
const originalWebkitAppearance = document.documentElement.style.WebkitAppearance;
const canvas = {
toDataURL: function () {
return 'data:image/png;base64,fake';
}
};
const clickSpy = sinon.spy();
let chart;
$container.innerHTML = '';
chart = new OrgChart({
chartContainer: '#chart-container',
data: JSON.parse(JSON.stringify(ds)),
chartClass: 'blue'
});
Object.defineProperty(window, 'navigator', {
configurable: true,
value: {
appName: 'Netscape',
appVersion: 'Chrome 123'
}
});
Object.defineProperty(window, 'sidebar', {
configurable: true,
value: false
});
document.documentElement.style.WebkitAppearance = 'none';
window.HTMLAnchorElement.prototype.click = clickSpy;
chart.exportPNG(canvas, 'ChartOne');
chart.exportPNG(canvas, 'ChartTwo');
queryAll('.download-btn.blue', chart.chartContainer).length.should.equal(1);
query('.download-btn.blue', chart.chartContainer).getAttribute('download').should.equal('ChartTwo.png');
clickSpy.callCount.should.equal(2);
Object.defineProperty(window, 'navigator', {
configurable: true,
value: originalNavigator
});
Object.defineProperty(window, 'sidebar', {
configurable: true,
value: originalSidebar
});
document.documentElement.style.WebkitAppearance = originalWebkitAppearance;
window.HTMLAnchorElement.prototype.click = originalClick;
});
it('export() returns false when there is no chart container', function () {
const chart = new OrgChart({ data: JSON.parse(JSON.stringify(ds)) });
chart.chartContainer = null;
chart.options = Object.assign({}, chart.defaultOptions);
chart.export('MissingChart').should.equal(false);
});
it('export() creates a mask when one is missing and routes png exports to exportPNG()', function (done) {
const originalHtml2canvas = global.html2canvas;
const originalWindowHtml2canvas = window.html2canvas;
const exportPNGStub = sinon.stub(oc, 'exportPNG');
const fakeCanvas = {
toDataURL: function () {
return 'data:image/png;base64,fake';
}
};
global.html2canvas = function (element, options) {
try {
element.should.equal(oc.chart);
options.width.should.equal(oc.chart.clientWidth);
options.height.should.equal(oc.chart.clientHeight);
options.scale.should.equal(2);
options.useCORS.should.equal(true);
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done(error);
return { then: function () {} };
}
return {
then: function (resolve) {
resolve(fakeCanvas);
return { then: function () {} };
}
};
};
window.html2canvas = global.html2canvas;
oc.export('UnitExport');
setTimeout(function () {
try {
exportPNGStub.should.have.been.calledOnce;
exportPNGStub.firstCall.args[0].should.equal(fakeCanvas);
exportPNGStub.firstCall.args[1].should.equal('UnitExport');
query('.mask', $container).classList.contains('hidden').should.equal(true);
$container.classList.contains('canvasContainer').should.equal(false);
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done();
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done(error);
}
}, 0);
});
it('export() reuses an existing mask by unhiding it', function (done) {
const originalHtml2canvas = global.html2canvas;
const originalWindowHtml2canvas = window.html2canvas;
const exportPNGStub = sinon.stub(oc, 'exportPNG');
const maskEl = document.createElement('div');
const fakeCanvas = { toDataURL: function () { return 'data:image/png;base64,fake'; } };
maskEl.className = 'mask hidden';
$container.appendChild(maskEl);
global.html2canvas = function () {
return {
then: function (resolve) {
resolve(fakeCanvas);
return { then: function () {} };
}
};
};
window.html2canvas = global.html2canvas;
oc.export('ReuseMask');
setTimeout(function () {
try {
maskEl.classList.contains('hidden').should.equal(true);
exportPNGStub.should.have.been.calledOnce;
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done();
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done(error);
}
}, 0);
});
it('export() swaps width and height for horizontal charts', function (done) {
const originalHtml2canvas = global.html2canvas;
const originalWindowHtml2canvas = window.html2canvas;
const exportPNGStub = sinon.stub(oc, 'exportPNG');
const fakeCanvas = { toDataURL: function () { return 'data:image/png;base64,fake'; } };
Object.defineProperty(oc.chart, 'clientWidth', { configurable: true, value: 300 });
Object.defineProperty(oc.chart, 'clientHeight', { configurable: true, value: 120 });
oc.options.direction = 'l2r';
global.html2canvas = function (element, options) {
try {
options.width.should.equal(120);
options.height.should.equal(300);
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done(error);
return { then: function () {} };
}
return {
then: function (resolve) {
resolve(fakeCanvas);
return { then: function () {} };
}
};
};
window.html2canvas = global.html2canvas;
oc.export('Horizontal');
setTimeout(function () {
try {
exportPNGStub.should.have.been.calledOnce;
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done();
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done(error);
}
}, 0);
});
it('export() clears cloned chart transforms inside onclone()', function (done) {
const originalHtml2canvas = global.html2canvas;
const originalWindowHtml2canvas = window.html2canvas;
const exportPNGStub = sinon.stub(oc, 'exportPNG');
const fakeCanvas = { toDataURL: function () { return 'data:image/png;base64,fake'; } };
const clonedContainer = document.createElement('div');
const clonedChart = document.createElement('div');
clonedContainer.className = 'canvasContainer';
clonedChart.className = 'orgchart';
clonedChart.style.transform = 'matrix(1, 0, 0, 1, 10, 20)';
clonedContainer.appendChild(clonedChart);
global.html2canvas = function (element, options) {
options.onclone({ querySelector: function (selector) {
return selector === '.canvasContainer' ? clonedContainer : null;
} });
return {
then: function (resolve) {
resolve(fakeCanvas);
return { then: function () {} };
}
};
};
window.html2canvas = global.html2canvas;
oc.export('CloneCleanup');
setTimeout(function () {
try {
clonedContainer.style.overflow.should.equal('visible');
clonedChart.style.transform.should.equal('');
exportPNGStub.should.have.been.calledOnce;
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done();
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPNGStub.restore();
done(error);
}
}, 0);
});
it('export() routes pdf exports to exportPDF()', function (done) {
const originalHtml2canvas = global.html2canvas;
const originalWindowHtml2canvas = window.html2canvas;
const exportPDFStub = sinon.stub(oc, 'exportPDF');
const fakeCanvas = { toDataURL: function () { return 'data:image/png;base64,fake'; } };
global.html2canvas = function () {
return {
then: function (resolve) {
resolve(fakeCanvas);
return { then: function () {} };
}
};
};
window.html2canvas = global.html2canvas;
oc.export('PdfExport', 'pdf');
setTimeout(function () {
try {
exportPDFStub.should.have.been.calledOnce;
exportPDFStub.firstCall.args[0].should.equal(fakeCanvas);
exportPDFStub.firstCall.args[1].should.equal('PdfExport');
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPDFStub.restore();
done();
} catch (error) {
global.html2canvas = originalHtml2canvas;
window.html2canvas = originalWindowHtml2canvas;
exportPDFStub.restore();
done(error);
}
}, 0);
});
});
});