浏览代码

Changed: Refactor InsertRowCommand to TableUtils plugin and introduce new commands: "insertRowAbove" & "insertRowBelow".

Maciej Gołaszewski 7 年之前
父节点
当前提交
82af5f7922

+ 25 - 53
packages/ckeditor5-table/src/commands/insertrowcommand.js

@@ -8,8 +8,8 @@
  */
 
 import Command from '@ckeditor/ckeditor5-core/src/command';
-import TableWalker from '../tablewalker';
-import { getColumns, getParentTable } from './utils';
+import { getParentTable } from './utils';
+import TableUtils from '../tableutils';
 
 /**
  * The insert row command.
@@ -18,6 +18,19 @@ import { getColumns, getParentTable } from './utils';
  */
 export default class InsertRowCommand extends Command {
 	/**
+	 * Creates a new `InsertRowCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor Editor on which this command will be used.
+	 * @param {Object} options
+	 * @param {String} [options.location="below"] Where to insert new row - relative to current row. Possible values: "above", "below".
+	 */
+	constructor( editor, options = {} ) {
+		super( editor );
+
+		this.direction = options.location || 'below';
+	}
+
+	/**
 	 * @inheritDoc
 	 */
 	refresh() {
@@ -32,65 +45,24 @@ export default class InsertRowCommand extends Command {
 	/**
 	 * Executes the command.
 	 *
-	 * @param {Object} [options] Options for the executed command.
-	 * @param {Number} [options.rows=1] Number of rows to insert.
-	 * @param {Number} [options.at=0] Row index to insert at.
-	 *
 	 * @fires execute
 	 */
-	execute( options = {} ) {
-		const model = this.editor.model;
-		const document = model.document;
-		const selection = document.selection;
+	execute() {
+		const editor = this.editor;
+		const model = editor.model;
+		const doc = model.document;
+		const selection = doc.selection;
 
-		const rows = parseInt( options.rows ) || 1;
-		const insertAt = parseInt( options.at ) || 0;
+		const element = doc.selection.getFirstPosition().parent;
 
 		const table = getParentTable( selection.getFirstPosition() );
 
-		const headingRows = table.getAttribute( 'headingRows' ) || 0;
-
-		const columns = getColumns( table );
-
-		model.change( writer => {
-			if ( headingRows > insertAt ) {
-				writer.setAttribute( 'headingRows', headingRows + rows, table );
-			}
-
-			const tableIterator = new TableWalker( table, { endRow: insertAt + 1 } );
-
-			let tableCellToInsert = 0;
-
-			for ( const tableCellInfo of tableIterator ) {
-				const { row, rowspan, colspan, cell } = tableCellInfo;
-
-				if ( row < insertAt ) {
-					if ( rowspan > 1 ) {
-						// check whether rowspan overlaps inserts:
-						if ( row < insertAt && row + rowspan > insertAt ) {
-							writer.setAttribute( 'rowspan', rowspan + rows, cell );
-						}
-					}
-				} else if ( row === insertAt ) {
-					tableCellToInsert += colspan;
-				}
-			}
-
-			if ( insertAt >= table.childCount ) {
-				tableCellToInsert = columns;
-			}
-
-			for ( let i = 0; i < rows; i++ ) {
-				const tableRow = writer.createElement( 'tableRow' );
+		const tableUtils = editor.plugins.get( TableUtils );
 
-				writer.insert( tableRow, table, insertAt );
+		const rowIndex = table.getChildIndex( element.parent );
 
-				for ( let columnIndex = 0; columnIndex < tableCellToInsert; columnIndex++ ) {
-					const cell = writer.createElement( 'tableCell' );
+		const insertAt = this.direction === 'below' ? rowIndex + 1 : rowIndex;
 
-					writer.insert( cell, tableRow, 'end' );
-				}
-			}
-		} );
+		tableUtils.insertRow( table, { rows: 1, at: insertAt } );
 	}
 }

+ 11 - 2
packages/ckeditor5-table/src/tableediting.js

@@ -31,6 +31,7 @@ import SetTableHeadersCommand from './commands/settableheaderscommand';
 import { getParentTable } from './commands/utils';
 
 import './../theme/table.css';
+import TableUtils from './tableutils';
 
 /**
  * The table editing feature.
@@ -96,7 +97,8 @@ export default class TablesEditing extends Plugin {
 		conversion.for( 'dataDowncast' ).add( downcastAttributeChange( { attribute: 'headingColumns' } ) );
 
 		editor.commands.add( 'insertTable', new InsertTableCommand( editor ) );
-		editor.commands.add( 'insertRow', new InsertRowCommand( editor ) );
+		editor.commands.add( 'insertRowAbove', new InsertRowCommand( editor, { location: 'above' } ) );
+		editor.commands.add( 'insertRowBelow', new InsertRowCommand( editor, { location: 'below' } ) );
 		editor.commands.add( 'insertColumn', new InsertColumnCommand( editor ) );
 		editor.commands.add( 'splitCell', new SplitCellCommand( editor ) );
 		editor.commands.add( 'removeRow', new RemoveRowCommand( editor ) );
@@ -112,6 +114,13 @@ export default class TablesEditing extends Plugin {
 	}
 
 	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableUtils ];
+	}
+
+	/**
 	 * Handles {@link module:engine/view/document~Document#event:keydown keydown} events for 'Tab' key executed
 	 * when table widget is selected.
 	 *
@@ -192,7 +201,7 @@ export default class TablesEditing extends Plugin {
 		const isLastRow = currentRow === table.childCount - 1;
 
 		if ( isForward && isLastRow && isLastCellInRow ) {
-			editor.execute( 'insertRow', { at: table.childCount } );
+			editor.plugins.get( TableUtils ).insertRow( table, { at: table.childCount } );
 		}
 
 		let moveToCell;

+ 4 - 3
packages/ckeditor5-table/src/tableui.js

@@ -46,8 +46,8 @@ export default class TableUI extends Plugin {
 			return buttonView;
 		} );
 
-		editor.ui.componentFactory.add( 'insertRow', locale => {
-			const command = editor.commands.get( 'insertRow' );
+		editor.ui.componentFactory.add( 'insertRowBelow', locale => {
+			const command = editor.commands.get( 'insertRowBelow' );
 			const buttonView = new ButtonView( locale );
 
 			buttonView.bind( 'isEnabled' ).to( command );
@@ -59,12 +59,13 @@ export default class TableUI extends Plugin {
 			} );
 
 			buttonView.on( 'execute', () => {
-				editor.execute( 'insertRow' );
+				editor.execute( 'insertRowBelow' );
 				editor.editing.view.focus();
 			} );
 
 			return buttonView;
 		} );
+
 		editor.ui.componentFactory.add( 'insertColumn', locale => {
 			const command = editor.commands.get( 'insertColumn' );
 			const buttonView = new ButtonView( locale );

+ 71 - 0
packages/ckeditor5-table/src/tableutils.js

@@ -0,0 +1,71 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/**
+ * @module table/commands/insertrowcommand
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import TableWalker from './tablewalker';
+import { getColumns } from './commands/utils';
+
+/**
+ * The table utils plugin.
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableUtils extends Plugin {
+	insertRow( table, options = {} ) {
+		const model = this.editor.model;
+
+		const rows = parseInt( options.rows ) || 1;
+		const insertAt = parseInt( options.at ) || 0;
+
+		const headingRows = table.getAttribute( 'headingRows' ) || 0;
+
+		const columns = getColumns( table );
+
+		model.change( writer => {
+			if ( headingRows > insertAt ) {
+				writer.setAttribute( 'headingRows', headingRows + rows, table );
+			}
+
+			const tableIterator = new TableWalker( table, { endRow: insertAt + 1 } );
+
+			let tableCellToInsert = 0;
+
+			for ( const tableCellInfo of tableIterator ) {
+				const { row, rowspan, colspan, cell } = tableCellInfo;
+
+				if ( row < insertAt ) {
+					if ( rowspan > 1 ) {
+						// check whether rowspan overlaps inserts:
+						if ( row < insertAt && row + rowspan > insertAt ) {
+							writer.setAttribute( 'rowspan', rowspan + rows, cell );
+						}
+					}
+				} else if ( row === insertAt ) {
+					tableCellToInsert += colspan;
+				}
+			}
+
+			if ( insertAt >= table.childCount ) {
+				tableCellToInsert = columns;
+			}
+
+			for ( let i = 0; i < rows; i++ ) {
+				const tableRow = writer.createElement( 'tableRow' );
+
+				writer.insert( tableRow, table, insertAt );
+
+				for ( let columnIndex = 0; columnIndex < tableCellToInsert; columnIndex++ ) {
+					const cell = writer.createElement( 'tableCell' );
+
+					writer.insert( cell, tableRow, 'end' );
+				}
+			}
+		} );
+	}
+}

+ 239 - 172
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -11,66 +11,71 @@ import InsertRowCommand from '../../src/commands/insertrowcommand';
 import { downcastInsertTable } from '../../src/converters/downcast';
 import upcastTable from '../../src/converters/upcasttable';
 import { formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import TableUtils from '../../src/tableutils';
 
 describe( 'InsertRowCommand', () => {
 	let editor, model, command;
 
 	beforeEach( () => {
-		return ModelTestEditor.create()
-			.then( newEditor => {
-				editor = newEditor;
-				model = editor.model;
-				command = new InsertRowCommand( editor );
-
-				const conversion = editor.conversion;
-				const schema = model.schema;
-
-				schema.register( 'table', {
-					allowWhere: '$block',
-					allowAttributes: [ 'headingRows' ],
-					isBlock: true,
-					isObject: true
-				} );
-
-				schema.register( 'tableRow', {
-					allowIn: 'table',
-					allowAttributes: [],
-					isBlock: true,
-					isLimit: true
-				} );
-
-				schema.register( 'tableCell', {
-					allowIn: 'tableRow',
-					allowContentOf: '$block',
-					allowAttributes: [ 'colspan', 'rowspan' ],
-					isBlock: true,
-					isLimit: true
-				} );
-
-				model.schema.register( 'p', { inheritAllFrom: '$block' } );
-
-				// Table conversion.
-				conversion.for( 'upcast' ).add( upcastTable() );
-				conversion.for( 'downcast' ).add( downcastInsertTable() );
-
-				// Table row upcast only since downcast conversion is done in `downcastTable()`.
-				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
-
-				// Table cell conversion.
-				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
-				conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
-
-				conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
-				conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isBlock: true,
+				isObject: true
 			} );
+
+			schema.register( 'tableRow', {
+				allowIn: 'table',
+				allowAttributes: [],
+				isBlock: true,
+				isLimit: true
+			} );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isBlock: true,
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Table row upcast only since downcast conversion is done in `downcastTable()`.
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
+
+			// Table cell conversion.
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+		} );
 	} );
 
 	afterEach( () => {
 		return editor.destroy();
 	} );
 
-	describe( 'isEnabled', () => {
-		describe( 'when selection is collapsed', () => {
+	describe( 'below', () => {
+		beforeEach( () => {
+			command = new InsertRowCommand( editor );
+		} );
+
+		describe( 'isEnabled', () => {
 			it( 'should be false if wrong node', () => {
 				setData( model, '<p>foo[]</p>' );
 				expect( command.isEnabled ).to.be.false;
@@ -81,145 +86,207 @@ describe( 'InsertRowCommand', () => {
 				expect( command.isEnabled ).to.be.true;
 			} );
 		} );
-	} );
 
-	describe( 'execute()', () => {
-		it( 'should insert row in given table at given index', () => {
-			setData( model, modelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ]
-			] ) );
+		describe( 'execute()', () => {
+			it( 'should insert row after current position', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
 
-			command.execute( { at: 1 } );
+				command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ '11[]', '12' ],
-				[ '', '' ],
-				[ '21', '22' ]
-			] ) );
-		} );
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00[]', '01' ],
+					[ '', '' ],
+					[ '10', '11' ]
+				] ) );
+			} );
 
-		it( 'should insert row in given table at default index', () => {
-			setData( model, modelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ]
-			] ) );
+			it( 'should update table heading rows attribute when inserting row in headings section', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00[]', '01' ],
+					[ '', '' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 3 } ) );
+			} );
 
-			command.execute();
+			it( 'should not update table heading rows attribute when inserting row after headings section', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ],
+					[ '', '' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+			} );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ '', '' ],
-				[ '11[]', '12' ],
-				[ '21', '22' ]
-			] ) );
-		} );
+			it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
+				setData( model, modelTable( [
+					[ { colspan: 2, contents: '00' }, '02', '03' ],
+					[ { colspan: 2, rowspan: 4, contents: '10[]' }, '12', '13' ],
+					[ '22', '23' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { colspan: 2, contents: '00' }, '02', '03' ],
+					[ { colspan: 2, rowspan: 5, contents: '10[]' }, '12', '13' ],
+					[ '', '' ],
+					[ '22', '23' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+			} );
 
-		it( 'should update table heading rows attribute when inserting row in headings section', () => {
-			setData( model, modelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ],
-				[ '31', '32' ]
-			], { headingRows: 2 } ) );
-
-			command.execute( { at: 1 } );
-
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ '11[]', '12' ],
-				[ '', '' ],
-				[ '21', '22' ],
-				[ '31', '32' ]
-			], { headingRows: 3 } ) );
-		} );
+			it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ '20', '21', '22' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ '', '', '' ],
+					[ '20', '21', '22' ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+			} );
 
-		it( 'should not update table heading rows attribute when inserting row after headings section', () => {
-			setData( model, modelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ],
-				[ '31', '32' ]
-			], { headingRows: 2 } ) );
-
-			command.execute( { at: 2 } );
-
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ],
-				[ '', '' ],
-				[ '31', '32' ]
-			], { headingRows: 2 } ) );
-		} );
+			it( 'should properly calculate columns if next row has colspans', () => {
+				setData( model, modelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ { colspan: 3, contents: '20' } ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ { rowspan: 2, contents: '00' }, '01', '02' ],
+					[ '11[]', '12' ],
+					[ '', '', '' ],
+					[ { colspan: 3, contents: '20' } ]
+				], { headingColumns: 3, headingRows: 1 } ) );
+			} );
+
+			it( 'should insert rows at the end of a table', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ]
+				] ) );
+
+				command.execute();
 
-		it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
-			setData( model, modelTable( [
-				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
-				[ { colspan: 2, rowspan: 4, contents: '21' }, '23', '24' ],
-				[ '33', '34' ]
-			], { headingColumns: 3, headingRows: 1 } ) );
-
-			command.execute( { at: 2, rows: 3 } );
-
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
-				[ { colspan: 2, rowspan: 7, contents: '21' }, '23', '24' ],
-				[ '', '' ],
-				[ '', '' ],
-				[ '', '' ],
-				[ '33', '34' ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10[]', '11' ],
+					[ '', '' ]
+				] ) );
+			} );
 		} );
+	} );
 
-		it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
-			setData( model, modelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
-				[ '31', '32', '33' ]
-			], { headingColumns: 3, headingRows: 1 } ) );
-
-			command.execute( { at: 2, rows: 3 } );
-
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
-				[ '', '', '' ],
-				[ '', '', '' ],
-				[ '', '', '' ],
-				[ '31', '32', '33' ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+	describe( 'location=above', () => {
+		beforeEach( () => {
+			command = new InsertRowCommand( editor, { location: 'above' } );
 		} );
 
-		it( 'should properly calculate columns if next row has colspans', () => {
-			setData( model, modelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
-				[ { colspan: 3, contents: '31' } ]
-			], { headingColumns: 3, headingRows: 1 } ) );
-
-			command.execute( { at: 2, rows: 3 } );
-
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
-				[ '22', '23' ],
-				[ '', '', '' ],
-				[ '', '', '' ],
-				[ '', '', '' ],
-				[ { colspan: 3, contents: '31' } ]
-			], { headingColumns: 3, headingRows: 1 } ) );
+		describe( 'isEnabled', () => {
+			it( 'should be false if wrong node', () => {
+				setData( model, '<p>foo[]</p>' );
+				expect( command.isEnabled ).to.be.false;
+			} );
+
+			it( 'should be true if in table', () => {
+				setData( model, modelTable( [ [ '[]' ] ] ) );
+				expect( command.isEnabled ).to.be.true;
+			} );
 		} );
 
-		it( 'should insert rows at the end of a table', () => {
-			setData( model, modelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ]
-			] ) );
-
-			command.execute( { at: 2, rows: 3 } );
-
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
-				[ '11[]', '12' ],
-				[ '21', '22' ],
-				[ '', '' ],
-				[ '', '' ],
-				[ '', '' ]
-			] ) );
+		describe( 'execute()', () => {
+			it( 'should insert row at the beginning of a table', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '', '' ],
+					[ '00[]', '01' ],
+					[ '10', '11' ]
+				] ) );
+			} );
+			it( 'should insert row at the end of a table', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20[]', '21' ]
+				] ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '', '' ],
+					[ '20[]', '21' ]
+				] ) );
+			} );
+
+			it( 'should update table heading rows attribute when inserting row in headings section', () => {
+				setData( model, modelTable( [
+					[ '00[]', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '', '' ],
+					[ '00[]', '01' ],
+					[ '10', '11' ],
+					[ '20', '21' ]
+				], { headingRows: 3 } ) );
+			} );
+
+			it( 'should not update table heading rows attribute when inserting row after headings section', () => {
+				setData( model, modelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '20[]', '21' ]
+				], { headingRows: 2 } ) );
+
+				command.execute();
+
+				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+					[ '00', '01' ],
+					[ '10', '11' ],
+					[ '', '' ],
+					[ '20[]', '21' ]
+				], { headingRows: 2 } ) );
+			} );
 		} );
 	} );
 } );

+ 1 - 1
packages/ckeditor5-table/tests/manual/table.js

@@ -13,7 +13,7 @@ ClassicEditor
 	.create( document.querySelector( '#editor' ), {
 		plugins: [ ArticlePluginSet, Table ],
 		toolbar: [
-			'heading', '|', 'insertTable', 'insertRow', 'insertColumn',
+			'heading', '|', 'insertTable', 'insertRowBelow', 'insertColumn',
 			'|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
 		]
 	} )

+ 213 - 0
packages/ckeditor5-table/tests/tableutils.js

@@ -0,0 +1,213 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { upcastElementToElement } from '@ckeditor/ckeditor5-engine/src/conversion/upcast-converters';
+
+import { downcastInsertTable } from '../src/converters/downcast';
+import upcastTable from '../src/converters/upcasttable';
+import { formatTable, formattedModelTable, modelTable } from './_utils/utils';
+import TableUtils from '../src/tableutils';
+
+describe( 'TableUtils', () => {
+	let editor, model, root, tableUtils;
+
+	beforeEach( () => {
+		return ModelTestEditor.create( {
+			plugins: [ TableUtils ]
+		} ).then( newEditor => {
+			editor = newEditor;
+			model = editor.model;
+			root = model.document.getRoot( 'main' );
+			tableUtils = editor.plugins.get( TableUtils );
+
+			const conversion = editor.conversion;
+			const schema = model.schema;
+
+			schema.register( 'table', {
+				allowWhere: '$block',
+				allowAttributes: [ 'headingRows' ],
+				isBlock: true,
+				isObject: true
+			} );
+
+			schema.register( 'tableRow', {
+				allowIn: 'table',
+				allowAttributes: [],
+				isBlock: true,
+				isLimit: true
+			} );
+
+			schema.register( 'tableCell', {
+				allowIn: 'tableRow',
+				allowContentOf: '$block',
+				allowAttributes: [ 'colspan', 'rowspan' ],
+				isBlock: true,
+				isLimit: true
+			} );
+
+			model.schema.register( 'p', { inheritAllFrom: '$block' } );
+
+			// Table conversion.
+			conversion.for( 'upcast' ).add( upcastTable() );
+			conversion.for( 'downcast' ).add( downcastInsertTable() );
+
+			// Table row upcast only since downcast conversion is done in `downcastTable()`.
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableRow', view: 'tr' } ) );
+
+			// Table cell conversion.
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'td' } ) );
+			conversion.for( 'upcast' ).add( upcastElementToElement( { model: 'tableCell', view: 'th' } ) );
+
+			conversion.attributeToAttribute( { model: 'colspan', view: 'colspan' } );
+			conversion.attributeToAttribute( { model: 'rowspan', view: 'rowspan' } );
+		} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( 'insertRow()', () => {
+		it( 'should insert row in given table at given index', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '', '' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should insert row in given table at default index', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ) );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '', '' ],
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+		} );
+
+		it( 'should update table heading rows attribute when inserting row in headings section', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 1 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '', '' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 3 } ) );
+		} );
+
+		it( 'should not update table heading rows attribute when inserting row after headings section', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 2 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '', '' ],
+				[ '31', '32' ]
+			], { headingRows: 2 } ) );
+		} );
+
+		it( 'should expand rowspan of a cell that overlaps inserted rows', () => {
+			setData( model, modelTable( [
+				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
+				[ { colspan: 2, rowspan: 4, contents: '21' }, '23', '24' ],
+				[ '33', '34' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
+				[ { colspan: 2, rowspan: 7, contents: '21' }, '23', '24' ],
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ],
+				[ '33', '34' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+		} );
+
+		it( 'should not expand rowspan of a cell that does not overlaps inserted rows', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ '31', '32', '33' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ '31', '32', '33' ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+		} );
+
+		it( 'should properly calculate columns if next row has colspans', () => {
+			setData( model, modelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ { colspan: 3, contents: '31' } ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
+				[ '22', '23' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ '', '', '' ],
+				[ { colspan: 3, contents: '31' } ]
+			], { headingColumns: 3, headingRows: 1 } ) );
+		} );
+
+		it( 'should insert rows at the end of a table', () => {
+			setData( model, modelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ]
+			] ) );
+
+			tableUtils.insertRow( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
+
+			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				[ '11[]', '12' ],
+				[ '21', '22' ],
+				[ '', '' ],
+				[ '', '' ],
+				[ '', '' ]
+			] ) );
+		} );
+	} );
+} );