diff --git a/src/jsmind.js b/src/jsmind.js index 46000fb7..ec0794af 100644 --- a/src/jsmind.js +++ b/src/jsmind.js @@ -414,6 +414,40 @@ export default class jsMind { } return this.mind.get_node(node); } + /** + * Add node data to the mind map without triggering UI refresh. + * @private + * @param {import('./jsmind.node.js').Node} parent_node + * @param {string} node_id + * @param {string} topic + * @param {Record=} data + * @param {('left'|'center'|'right'|'-1'|'0'|'1'|number)=} direction + * @returns {import('./jsmind.node.js').Node|null} + */ + _add_node_data(parent_node, node_id, topic, data, direction) { + var dir = Direction.of(direction); + if (dir === undefined) { + dir = this.layout.calculate_next_child_direction(parent_node); + } + var node = this.mind.add_node(parent_node, node_id, topic, data, dir); + if (!!node) { + this.view.add_node(node); + this.view.reset_node_custom_style(node); + } + return node; + } + + /** + * Refresh UI after node changes. + * @private + * @param {import('./jsmind.node.js').Node} parent_node + */ + _refresh_node_ui(parent_node) { + this.layout.layout(); + this.view.show(false); + this.expand_node(parent_node); + } + /** * Add a new node to the mind map. * @param {string | import('./jsmind.node.js').Node} parent_node @@ -424,30 +458,78 @@ export default class jsMind { * @returns {import('./jsmind.node.js').Node|null} */ add_node(parent_node, node_id, topic, data, direction) { - if (this.get_editable()) { - var the_parent_node = this.get_node(parent_node); - var dir = Direction.of(direction); - if (dir === undefined) { - dir = this.layout.calculate_next_child_direction(the_parent_node); - } - var node = this.mind.add_node(the_parent_node, node_id, topic, data, dir); - if (!!node) { - this.view.add_node(node); - this.layout.layout(); - this.view.show(false); - this.view.reset_node_custom_style(node); - this.expand_node(the_parent_node); - this.invoke_event_handle(EventType.edit, { - evt: 'add_node', - data: [the_parent_node.id, node_id, topic, data, dir], - node: node_id, - }); - } - return node; - } else { + if (!this.get_editable()) { logger.error('fail, this mind map is not editable'); return null; } + + var the_parent_node = this.get_node(parent_node); + if (!the_parent_node) { + logger.error('parent node not found'); + return null; + } + + var node = this._add_node_data(the_parent_node, node_id, topic, data, direction); + if (!!node) { + this._refresh_node_ui(the_parent_node); + this.invoke_event_handle(EventType.edit, { + evt: 'add_node', + data: [the_parent_node.id, node_id, topic, data, Direction.of(direction)], + node: node_id, + }); + } + return node; + } + + /** + * Add multiple nodes to the mind map with optimized performance. + * @param {string | import('./jsmind.node.js').Node} parent_node - Parent node for all new nodes + * @param {Array<{node_id: string, topic: string, data?: Record, direction?: ('left'|'center'|'right'|'-1'|'0'|'1'|number)}>} nodes_data - Array of node data objects + * @returns {Array} Array of created nodes + */ + add_nodes(parent_node, nodes_data) { + if (!this.get_editable()) { + logger.error('fail, this mind map is not editable'); + return []; + } + + var the_parent_node = this.get_node(parent_node); + if (!the_parent_node) { + logger.error('parent node not found'); + return []; + } + + if (!Array.isArray(nodes_data) || nodes_data.length === 0) { + logger.warn('nodes_data should be a non-empty array'); + return []; + } + + var created_nodes = []; + + // Batch create node data without triggering UI refresh + for (var i = 0; i < nodes_data.length; i++) { + var node_data = nodes_data[i]; + var node = this._add_node_data( + the_parent_node, + node_data.node_id, + node_data.topic, + node_data.data, + node_data.direction + ); + created_nodes.push(node); + } + + // Refresh UI once after all nodes are added + if (!!created_nodes.length) { + this._refresh_node_ui(the_parent_node); + this.invoke_event_handle(EventType.edit, { + evt: 'add_nodes', + data: [the_parent_node.id, nodes_data], + nodes: created_nodes.filter(node => node !== null).map(node => node.id), + }); + } + + return created_nodes; } /** * Insert a node before target node. diff --git a/tests/unit/jsmind.test.js b/tests/unit/jsmind.test.js index 9a5547af..5d24dc1f 100644 --- a/tests/unit/jsmind.test.js +++ b/tests/unit/jsmind.test.js @@ -544,8 +544,380 @@ test('event listener', () => { expect(callback3).toBeCalledWith('a', 'b'); }); -function create_fake_mind() { - const jsmind = new jm({ container: 'container' }); +describe('add_nodes', () => { + // Error handling tests + test('should return empty array when not editable', () => { + const jsmind = new jm({ container: 'container', editable: false }); + const result = jsmind.add_nodes('root', []); + expect(result).toEqual([]); + }); + + test('should return empty array when parent node not found', () => { + const jsmind = create_fake_mind({ editable: true }); + const result = jsmind.add_nodes('nonexistent', []); + expect(result).toEqual([]); + }); + + test('should return empty array when nodes_data is not array', () => { + const jsmind = create_fake_mind({ editable: true }); + const result = jsmind.add_nodes('root', null); + expect(result).toEqual([]); + }); + + test('should return empty array when nodes_data is empty', () => { + const jsmind = create_fake_mind({ editable: true }); + const result = jsmind.add_nodes('root', []); + expect(result).toEqual([]); + }); + + test('should have add_nodes method', () => { + const jsmind = create_fake_mind({ editable: true }); + expect(typeof jsmind.add_nodes).toBe('function'); + }); + + // Core functionality tests - simplified to avoid DOM issues in test environment + test('should call _add_node_data for each node', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + const nodes_data = [ + { node_id: 'test1', topic: 'Test Node 1' }, + { node_id: 'test2', topic: 'Test Node 2' }, + ]; + + const result = jsmind.add_nodes('root', nodes_data); + + expect(jsmind._add_node_data).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + }); + + test('should call _refresh_node_ui once after all nodes added', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + const nodes_data = [ + { node_id: 'test1', topic: 'Test Node 1' }, + { node_id: 'test2', topic: 'Test Node 2' }, + { node_id: 'test3', topic: 'Test Node 3' }, + ]; + + jsmind.add_nodes('root', nodes_data); + + expect(jsmind._refresh_node_ui).toHaveBeenCalledTimes(1); + expect(jsmind._refresh_node_ui).toHaveBeenCalledWith(jsmind.mind.root); + }); + + test('should trigger add_nodes event with correct data', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + const eventHandler = jest.fn(); + jsmind.invoke_event_handle = eventHandler; + + const nodes_data = [ + { node_id: 'test1', topic: 'Test Node 1' }, + { node_id: 'test2', topic: 'Test Node 2' }, + ]; + + jsmind.add_nodes('root', nodes_data); + + expect(eventHandler).toHaveBeenCalledWith(EventType.edit, { + evt: 'add_nodes', + data: ['root', nodes_data], + nodes: ['test', 'test'], // Mock returns same object + }); + }); + + test('should handle failed node creation', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest + .fn() + .mockReturnValueOnce({ id: 'test1', topic: 'Test Node 1' }) + .mockReturnValueOnce(null) // Second node fails + .mockReturnValueOnce({ id: 'test3', topic: 'Test Node 3' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + const nodes_data = [ + { node_id: 'test1', topic: 'Test Node 1' }, + { node_id: 'test2', topic: 'Test Node 2' }, + { node_id: 'test3', topic: 'Test Node 3' }, + ]; + + const result = jsmind.add_nodes('root', nodes_data); + + expect(result).toHaveLength(3); + expect(result[0]).not.toBeNull(); + expect(result[1]).toBeNull(); + expect(result[2]).not.toBeNull(); + }); + + test('should pass correct parameters to _add_node_data', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + const nodes_data = [ + { + node_id: 'test1', + topic: 'Test Node 1', + data: { color: 'red' }, + direction: 'right', + }, + ]; + + jsmind.add_nodes('root', nodes_data); + + expect(jsmind._add_node_data).toHaveBeenCalledWith( + jsmind.mind.root, + 'test1', + 'Test Node 1', + { color: 'red' }, + 'right' + ); + }); + + // Performance and scalability tests + test('should handle large number of nodes efficiently', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + // Generate 1000 nodes + const nodes_data = []; + for (let i = 0; i < 1000; i++) { + nodes_data.push({ + node_id: `bulk_node_${i}`, + topic: `Bulk Node ${i}`, + data: { index: i }, + direction: i % 2 === 0 ? 'right' : 'left', + }); + } + + const start = performance.now(); + const result = jsmind.add_nodes('root', nodes_data); + const end = performance.now(); + + expect(result).toHaveLength(1000); + expect(jsmind._add_node_data).toHaveBeenCalledTimes(1000); + expect(jsmind._refresh_node_ui).toHaveBeenCalledTimes(1); // Key performance benefit + expect(end - start).toBeLessThan(100); // Should complete within 100ms + }); + + test('should handle complex node data structures', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + const complexData = { + 'background-color': '#ff6b6b', + 'font-size': '14px', + 'font-weight': 'bold', + 'border-radius': '8px', + 'padding': '10px', + 'custom-attribute': 'value', + 'nested-object': { + level1: { + level2: 'deep-value', + }, + }, + 'array-data': [1, 2, 3, 'string', { key: 'value' }], + 'boolean-flag': true, + 'numeric-value': 42.5, + 'null-value': null, + 'undefined-value': undefined, + }; + + const nodes_data = [ + { + node_id: 'complex1', + topic: 'Complex Node with Rich Data', + data: complexData, + direction: 'right', + }, + { + node_id: 'complex2', + topic: 'Node with Unicode: 🌟 测试 العربية', + data: { + 'unicode-support': '🎉🚀💡', + 'multilingual': { + chinese: '你好世界', + arabic: 'مرحبا بالعالم', + emoji: '😀🎈🌈', + }, + }, + direction: 'left', + }, + ]; + + const result = jsmind.add_nodes('root', nodes_data); + + expect(result).toHaveLength(2); + expect(jsmind._add_node_data).toHaveBeenCalledWith( + jsmind.mind.root, + 'complex1', + 'Complex Node with Rich Data', + complexData, + 'right' + ); + expect(jsmind._add_node_data).toHaveBeenCalledWith( + jsmind.mind.root, + 'complex2', + 'Node with Unicode: 🌟 测试 العربية', + expect.objectContaining({ + 'unicode-support': '🎉🚀💡', + 'multilingual': expect.any(Object), + }), + 'left' + ); + }); + + test('should handle all direction types correctly', () => { + const jsmind = create_fake_mind({ editable: true }); + jsmind._add_node_data = jest.fn().mockReturnValue({ id: 'test', topic: 'test' }); + jsmind._refresh_node_ui = jest.fn(); + jsmind.invoke_event_handle = jest.fn(); + + const nodes_data = [ + { node_id: 'dir1', topic: 'Left String', direction: 'left' }, + { node_id: 'dir2', topic: 'Right String', direction: 'right' }, + { node_id: 'dir3', topic: 'Center String', direction: 'center' }, + { node_id: 'dir4', topic: 'Left Numeric', direction: -1 }, + { node_id: 'dir5', topic: 'Right Numeric', direction: 1 }, + { node_id: 'dir6', topic: 'Center Numeric', direction: 0 }, + { node_id: 'dir7', topic: 'Auto Direction' }, // No direction specified + { node_id: 'dir8', topic: 'Null Direction', direction: null }, + { node_id: 'dir9', topic: 'Undefined Direction', direction: undefined }, + { node_id: 'dir10', topic: 'Invalid String', direction: 'invalid' }, + { node_id: 'dir11', topic: 'Invalid Number', direction: 999 }, + { node_id: 'dir12', topic: 'Boolean Direction', direction: true }, + { node_id: 'dir13', topic: 'Object Direction', direction: { side: 'right' } }, + ]; + + const result = jsmind.add_nodes('root', nodes_data); + + expect(result).toHaveLength(13); + expect(jsmind._add_node_data).toHaveBeenCalledTimes(13); + + // Verify each direction type is passed correctly + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 1, + jsmind.mind.root, + 'dir1', + 'Left String', + undefined, + 'left' + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 2, + jsmind.mind.root, + 'dir2', + 'Right String', + undefined, + 'right' + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 3, + jsmind.mind.root, + 'dir3', + 'Center String', + undefined, + 'center' + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 4, + jsmind.mind.root, + 'dir4', + 'Left Numeric', + undefined, + -1 + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 5, + jsmind.mind.root, + 'dir5', + 'Right Numeric', + undefined, + 1 + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 6, + jsmind.mind.root, + 'dir6', + 'Center Numeric', + undefined, + 0 + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 7, + jsmind.mind.root, + 'dir7', + 'Auto Direction', + undefined, + undefined + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 8, + jsmind.mind.root, + 'dir8', + 'Null Direction', + undefined, + null + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 9, + jsmind.mind.root, + 'dir9', + 'Undefined Direction', + undefined, + undefined + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 10, + jsmind.mind.root, + 'dir10', + 'Invalid String', + undefined, + 'invalid' + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 11, + jsmind.mind.root, + 'dir11', + 'Invalid Number', + undefined, + 999 + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 12, + jsmind.mind.root, + 'dir12', + 'Boolean Direction', + undefined, + true + ); + expect(jsmind._add_node_data).toHaveBeenNthCalledWith( + 13, + jsmind.mind.root, + 'dir13', + 'Object Direction', + undefined, + { side: 'right' } + ); + }); +}); + +function create_fake_mind(options = {}) { + const defaultOptions = { container: 'container' }; + const mergedOptions = { ...defaultOptions, ...options }; + const jsmind = new jm(mergedOptions); const mind = new jm.mind(); const root_node = mind.set_root('root', 'root'); mind.add_node(root_node, 'node1', 'node1');