Repository: ProcessMaker/modeler (js)

Issue:
Message Flow are not available for some elements
# Message Flow are not available for some elements
## Description
Currently there are elements that could contain message elements but can not be connected using the message flows.

## Reproduction steps
Insert two pools, one pool with and End Message Event, the other one with and Start Message Event.

Try to draw a message flow between those elements.

End Message Flow Event --> Start Message Event

## Expected behavior
Any throw element (ex. Intermediate throw and End Event) with a Message Event Definition, must have a Message Flow button in its crown.

Any catch element (ex. Intermediate catch, boundary and Start Events) with a Message Event Definition, can be a valid target for a message flow.

![image](https://user-images.githubusercontent.com/8028650/74337392-95a01f00-4d76-11ea-92da-d53bb7d84471.png)

## Actual behavior
Not all the elements like End with message have the Message Flow

![image](https://user-images.githubusercontent.com/8028650/74337486-bec0af80-4d76-11ea-9e2d-879d9b71a5f3.png)


Pull request:
Fix allowed message flows
Fixes #1105.

<img width="630" alt="Screen Shot 2020-02-13 at 2 19 51 PM" src="https://user-images.githubusercontent.com/7561061/74470228-20167a80-4e6c-11ea-93a5-bd0c51f779f2.png">


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index a5376783..de6311b3 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -5,13 +5,17 @@
 version: 2.1
 orbs:
   # use Cypress orb from CircleCI registry
-  cypress: cypress-io/cypress@1.10.0
+  cypress: cypress-io/cypress@1.14.0
+executors:
+  with-chrome:
+    docker:
+      - image: 'cypress/browsers:node12.8.1-chrome80-ff72'
 workflows:
   build:
     jobs:
       # runs tests using Chrome browser in custom executor (Cypress docker image)
       - cypress/run:
-          executor: cypress/browsers-chrome76
+          executor: with-chrome
           browser: chrome
           command: 'npm install -g npm@latest && npm run lint && npm test'
           post-steps:
diff --git a/src/assets/distribute-horizontally-icon.svg b/src/assets/distribute-horizontally-icon.svg
new file mode 100644
index 00000000..d0e99cee
--- /dev/null
+++ b/src/assets/distribute-horizontally-icon.svg
@@ -0,0 +1,3 @@
+<svg width="29" height="30" viewBox="0 0 29 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M6.5 3.5H6V4V10H4V26H9V10H7V4.5H14.25V10H12.25V21H17.25V10H15.25V4.5H22V10H20V26H25V10H23V4V3.5H22.5H14.75H6.5Z" fill="white"/>
+</svg>
diff --git a/src/assets/distribute-vertically-icon.svg b/src/assets/distribute-vertically-icon.svg
new file mode 100644
index 00000000..2f555e1a
--- /dev/null
+++ b/src/assets/distribute-vertically-icon.svg
@@ -0,0 +1,3 @@
+<svg width="29" height="30" viewBox="0 0 29 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M9.5 7.49997V9.49997H25.5V4.49997H9.5V6.49997H3.5H3V6.99997V14.75V23V23.5H3.5H9.5V25.5H25.5V20.5H9.5V22.5H4V15.25H9.5V17.25H20.5V12.25H9.5V14.25H4V7.49997H9.5Z" fill="white"/>
+</svg>
diff --git a/src/components/crown/crownButtons/messageFlowButton.vue b/src/components/crown/crownButtons/messageFlowButton.vue
index 01d28db2..f7e7e5cb 100644
--- a/src/components/crown/crownButtons/messageFlowButton.vue
+++ b/src/components/crown/crownButtons/messageFlowButton.vue
@@ -1,6 +1,6 @@
 <template>
   <crown-button
-    v-if="validMessageFlowSources.includes(node.type)"
+    v-if="isValidMessageFlowSource"
     v-b-tooltip.hover.viewport.d50="{ customClass: 'no-pointer-events' }"
     :title="$t('Message Flow')"
     id="message-flow-button"
@@ -15,21 +15,27 @@ import messageFlow from '@/assets/message-flow.svg';
 import CrownButton from '@/components/crown/crownButtons/crownButton';
 import Node from '@/components/nodes/node';
 
+const validMessageFlowSourceTypes = [
+  'processmaker-modeler-message-end-event',
+  'processmaker-modeler-pool',
+  'processmaker-modeler-task',
+  'processmaker-modeler-call-activity',
+];
+
 export default {
   components: { CrownButton },
   props: ['node', 'moddle', 'shape'],
   data() {
     return {
       messageFlow,
-      validMessageFlowSources: [
-        'processmaker-modeler-start-event',
-        'processmaker-modeler-end-event',
-        'processmaker-modeler-task',
-        'processmaker-modeler-pool',
-        'processmaker-modeler-intermediate-message-throw-event',
-      ],
     };
   },
+  computed: {
+    isValidMessageFlowSource() {
+      return this.node.isBpmnType('bpmn:IntermediateThrowEvent') ||
+        validMessageFlowSourceTypes.some(type => this.node.isType(type));
+    },
+  },
   methods: {
     addMessageFlow(cellView, evt, x, y) {
       this.$emit('toggle-crown-state', false);
diff --git a/src/components/modeler/Modeler.vue b/src/components/modeler/Modeler.vue
index 1bcb9efc..01c23629 100644
--- a/src/components/modeler/Modeler.vue
+++ b/src/components/modeler/Modeler.vue
@@ -10,6 +10,7 @@
       @toggle-panels-compressed="panelsCompressed = $event"
       @toggle-mini-map-open="miniMapOpen = $event"
       @saveBpmn="$emit('saveBpmn')"
+      @save-state="pushToUndoStack"
     />
     <b-row class="modeler h-100">
       <b-tooltip
diff --git a/src/components/modeler/setUpSelectionBox.js b/src/components/modeler/setUpSelectionBox.js
index 828edd25..02bb77ce 100644
--- a/src/components/modeler/setUpSelectionBox.js
+++ b/src/components/modeler/setUpSelectionBox.js
@@ -1,5 +1,6 @@
 import { g, shapes } from 'jointjs';
 import store from '@/store';
+import { canMoveProgrammatically } from '@/components/nodes/utilities/shapeMovement';
 
 const shapesToNotTranslate = [
   'processmaker.modeler.bpmn.pool',
@@ -99,7 +100,7 @@ export default function setUpSelectionBox(setCursor, resetCursor, paperManager,
   function moveAllOtherHighlightedShapes(element, newPosition, { movedWithArrowKeys }) {
     if (
       movedWithArrowKeys ||
-      shapesToNotTranslate.includes(element.get('type')) ||
+      !canMoveProgrammatically(element) ||
       !store.getters.highlightedShapes.includes(element) ||
       element.component.node.pool
     ) {
diff --git a/src/components/nodes/endEvent/index.js b/src/components/nodes/endEvent/index.js
index 168ee4e9..22847e61 100644
--- a/src/components/nodes/endEvent/index.js
+++ b/src/components/nodes/endEvent/index.js
@@ -26,9 +26,6 @@ export default {
       }),
     });
   },
-  allowOutgoingFlow() {
-    return false;
-  },
   inspectorConfig: [
     {
       name: 'End Event',
diff --git a/src/components/nodes/messageFlow/messageFlow.vue b/src/components/nodes/messageFlow/messageFlow.vue
index ec2f090f..2c782d10 100644
--- a/src/components/nodes/messageFlow/messageFlow.vue
+++ b/src/components/nodes/messageFlow/messageFlow.vue
@@ -71,6 +71,7 @@ export default {
         this.targetIsValidType() &&
         this.targetIsValidStartEventType() &&
         this.targetIsValidIntermediateEventType() &&
+        this.targetIsValidBoundaryEventType() &&
         this.targetIsNotContainingPool() &&
         this.targetIsInDifferentPool() &&
         this.targetIsNotSource() &&
@@ -90,6 +91,13 @@ export default {
 
       return this.targetNode.isType('processmaker-modeler-intermediate-message-catch-event');
     },
+    targetIsValidBoundaryEventType() {
+      if (!this.targetNode.isBpmnType('bpmn:BoundaryEvent')) {
+        return true;
+      }
+
+      return this.targetNode.isType('processmaker-modeler-boundary-message-event');
+    },
     targetIsValidType() {
       return [
         'bpmn:Task',
@@ -100,6 +108,7 @@ export default {
         'bpmn:IntermediateCatchEvent',
         'bpmn:Participant',
         'bpmn:StartEvent',
+        'bpmn:BoundaryEvent',
       ].some(type => this.targetNode.isBpmnType(type));
     },
     hasTargetType() {
@@ -124,8 +133,11 @@ export default {
       return this.targetNode.id !== this.sourceNode.id;
     },
     allowOutgoingFlow() {
-      return typeof this.sourceConfig.allowOutgoingFlow === 'undefined' ||
-        this.sourceConfig.allowOutgoingFlow(this.targetNode);
+      if ('allowOutgoingFlow' in this.sourceConfig) {
+        return this.sourceConfig.allowOutgoingFlow(this.targetNode);
+      }
+
+      return true;
     },
   },
   mounted() {
diff --git a/src/components/nodes/task/task.vue b/src/components/nodes/task/task.vue
index 6d537c01..13881550 100644
--- a/src/components/nodes/task/task.vue
+++ b/src/components/nodes/task/task.vue
@@ -26,6 +26,7 @@ import TaskShape from '@/components/nodes/task/shape';
 import { taskHeight } from './taskConfig';
 import hideLabelOnDrag from '@/mixins/hideLabelOnDrag';
 import CrownConfig from '@/components/crown/crownConfig/crownConfig';
+import { gridSize } from '@/graph';
 
 const labelPadding = 15;
 const topAndBottomMarkersSpace = 2 * markerSize;
@@ -108,13 +109,11 @@ export default {
     'node.definition.name'(name) {
       const { width } = this.node.diagram.bounds;
       this.shape.attr('label/text', util.breakText(name, { width }));
-
-      /* Update shape height if label text overflows */
-      const labelHeight = this.shapeView.selectors.label.getBBox().height;
       const { height } = this.shape.size();
 
-      if (labelHeight + labelPadding + topAndBottomMarkersSpace !== height) {
-        const newHeight = Math.max(labelHeight + labelPadding + topAndBottomMarkersSpace, taskHeight);
+      const heightByGrid = this.calculateSizeOnGrid();
+      const newHeight = this.useTaskHeight(heightByGrid) ? taskHeight : heightByGrid;
+      if (height !== newHeight) {
         this.node.diagram.bounds.height = newHeight;
         this.shape.resize(width, newHeight);
         this.recalcMarkersAlignment();
@@ -128,6 +127,22 @@ export default {
 
       return this.graph.findModelsInArea(area);
     },
+    calculateSizeOnGrid() {
+      const taskGridDifference = gridSize - (taskHeight % gridSize);
+      const labelHeight = Math.floor(this.shapeView.selectors.label.getBBox().height);
+      const labelSpace = labelHeight + labelPadding + topAndBottomMarkersSpace;
+      let newHeight = this.paperManager.ceilToNearestGridMultiple(labelSpace) - taskGridDifference;
+      if (this.middleIsOddNumber(newHeight)) {
+        newHeight += gridSize;
+      }
+      return newHeight;
+    },
+    useTaskHeight(height) {
+      return height < taskHeight || !this.node.definition.name;
+    },
+    middleIsOddNumber(value) {
+      return Math.abs((value / 2) % 2) === 1;
+    },
   },
   mounted() {
     this.shape = new TaskShape();
diff --git a/src/components/nodes/utilities/distribute.js b/src/components/nodes/utilities/distribute.js
index c3937420..b459f60a 100644
--- a/src/components/nodes/utilities/distribute.js
+++ b/src/components/nodes/utilities/distribute.js
@@ -1,5 +1,6 @@
-import { moveShapeLeftTo, moveShapeMiddleXTo, moveShapeMiddleYTo } from '@/components/nodes/utilities/shapeMovement';
+import { moveShapeLeftTo, moveShapeMiddleYTo, moveShapeTopTo } from '@/components/nodes/utilities/shapeMovement';
 import { getBoundingBox } from '@/components/nodes/utilities/shapeGroup';
+import { shapeCenterX, shapeCenterY } from '@/components/nodes/utilities/shapeMetrics';
 
 export function distributeVerticalCentersEvenly(shapes) {
   const bounds = getBoundingBox(shapes);
@@ -8,7 +9,7 @@ export function distributeVerticalCentersEvenly(shapes) {
     return;
   }
 
-  const topToBottom = shapes.sort((a, b) => a.position().y - b.position().y);
+  const topToBottom = shapes.sort((a, b) => shapeCenterY(a) - shapeCenterY(b));
   const topShape = topToBottom[0];
   const bottomShape = topToBottom[itemCount - 1];
 
@@ -27,42 +28,35 @@ export function distributeVerticalCentersEvenly(shapes) {
   });
 }
 
-export function distributeHorizontalCentersEvenly(shapes) {
+export function distributeVerticalSpacingEvenly(shapes) {
   const bounds = getBoundingBox(shapes);
   const itemCount = shapes.length;
-  if (itemCount < 3) {
+  const minimumArrangeable = 3; // less than 3 is evenly spaced by definition
+  if (itemCount < minimumArrangeable) {
     return;
   }
-
-  const leftToRight = shapes.sort((a, b) => a.position().x - b.position().x);
-  const leftmostShape = leftToRight[0];
-  const rightmostShape = leftToRight[itemCount - 1];
-
-  const availableWidth = bounds.width
-    - (leftmostShape.size().width / 2)
-    - (rightmostShape.size().width / 2);
-
-  const distanceBetweenCenters = availableWidth / (itemCount - 1);
-  const offset = bounds.left + (leftmostShape.size().width / 2);
-
-  leftToRight.forEach((shape, idx) => {
+  const totalShapeHeight = shapes.reduce((sum, shape) => shape.size().height + sum, 0);
+  const topToBottom = shapes.sort((a, b) => shapeCenterY(a) - shapeCenterY(b));
+  let offset = bounds.top + shapes[0].size().height;
+  const spaceBetween = (bounds.height - totalShapeHeight) / (topToBottom.length - 1);
+  topToBottom.forEach((shape, idx) => {
     if (idx === 0) {
       return;
     }
-
-    moveShapeMiddleXTo(shape, offset + idx * distanceBetweenCenters);
+    moveShapeTopTo(shape, offset + spaceBetween);
+    offset += spaceBetween + shape.size().height;
   });
 }
 
 export function distributeHorizontalSpacingEvenly(shapes) {
   const bounds = getBoundingBox(shapes);
   const itemCount = shapes.length;
-  const minimumArrangeable = 3; // less than 3 is evenly spaced by definition
+  const minimumArrangeable = 3;
   if (itemCount < minimumArrangeable) {
     return;
   }
   const totalShapeWidth = shapes.reduce((sum, shape) => shape.size().width + sum, 0);
-  const leftToRight = shapes.sort((a, b) => a.position().x - b.position().x);
+  const leftToRight = shapes.sort((a, b) => shapeCenterX(a) - shapeCenterX(b));
   let offset = bounds.left + shapes[0].size().width;
   const spaceBetween = (bounds.width - totalShapeWidth) / (leftToRight.length - 1);
   leftToRight.forEach((shape, idx) => {
diff --git a/src/components/nodes/utilities/shapeGroup.js b/src/components/nodes/utilities/shapeGroup.js
index 8bffab8a..0a3fd332 100644
--- a/src/components/nodes/utilities/shapeGroup.js
+++ b/src/components/nodes/utilities/shapeGroup.js
@@ -1,12 +1,40 @@
+import {
+  hasPositionAndSizeAttribs,
+  shapeBottom,
+  shapeCenterX,
+  shapeCenterY,
+  shapeLeft,
+  shapeRight,
+  shapeTop,
+} from '@/components/nodes/utilities/shapeMetrics';
+import {
+  alignBottom,
+  alignLeft,
+  alignRight,
+  alignTop,
+  centerHorizontally,
+  centerVertically,
+} from '@/components/nodes/utilities/align';
+import {
+  distributeHorizontalSpacingEvenly,
+  distributeVerticalSpacingEvenly,
+} from '@/components/nodes/utilities/distribute';
+import { canAlign } from '@/components/nodes/utilities/shapeMovement';
+
 /**
  * Utility functions that operate on a collection of shapes
  */
 
 export function getBoundingBox(shapes) {
-  const left = (shape) => shape.position().x;
-  const right = (shape) => shape.position().x + shape.size().width;
-  const top = (shape) => shape.position().y;
-  const bottom = (shape) => shape.position().y + shape.size().height;
+
+  if (!shapes || !shapes.length || shapes.length === 0) {
+    return { left: 0, right: 0, top: 0, bottom: 0, width: 0, height: 0, vMiddle: 0, hMiddle: 0 };
+  }
+
+  const left = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().x : 0;
+  const right = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().x + shape.size().width : 0;
+  const top = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().y : 0;
+  const bottom = (shape) => hasPositionAndSizeAttribs(shape) ? shape.position().y + shape.size().height : 0;
 
   const minX = Math.min(...shapes.map(left));
   const maxX = Math.max(...shapes.map(right));
@@ -26,3 +54,40 @@ export function getBoundingBox(shapes) {
     hMiddle: minX + (width / 2),
   };
 }
+
+/**
+ * Gets the available options for a group of shapes
+ */
+export function getShapesOptions(shapes) {
+  const alignableShapes = shapes.filter(shape => canAlign(shape));
+  const bounds = getBoundingBox(shapes);
+  const enoughShapes = alignableShapes.length > 1;
+  return {
+    can: {
+      align: {
+        left: enoughShapes && alignableShapes.some(shape => shapeLeft(shape) !== bounds.left),
+        horizontalCenter: enoughShapes && alignableShapes.some(shape => shapeCenterX(shape) !== bounds.hMiddle),
+        right: enoughShapes && alignableShapes.some(shape => shapeRight(shape) !== bounds.right),
+        bottom: enoughShapes && alignableShapes.some(shape => shapeBottom(shape) !== bounds.bottom),
+        verticalCenter: enoughShapes && alignableShapes.some(shape => shapeCenterY(shape) !== bounds.vMiddle),
+        top: enoughShapes && alignableShapes.some(shape => shapeTop(shape) !== bounds.top),
+      },
+      distribute: {
+        horizontally: enoughShapes && alignableShapes.length > 2,
+        vertically: enoughShapes && alignableShapes.length > 2,
+      },
+    },
+    align: {
+      left: () => alignLeft(alignableShapes),
+      horizontalCenter: () => centerHorizontally(alignableShapes),
+      right: () => alignRight(alignableShapes),
+      bottom: () => alignBottom(alignableShapes),
+      verticalCenter: () => centerVertically(alignableShapes),
+      top: () => alignTop(alignableShapes),
+    },
+    distribute: {
+      horizontally: () => distributeHorizontalSpacingEvenly(alignableShapes),
+      vertically: () => distributeVerticalSpacingEvenly(alignableShapes),
+    },
+  };
+}
diff --git a/src/components/nodes/utilities/shapeMetrics.js b/src/components/nodes/utilities/shapeMetrics.js
index b84447bc..8e7247f3 100644
--- a/src/components/nodes/utilities/shapeMetrics.js
+++ b/src/components/nodes/utilities/shapeMetrics.js
@@ -4,3 +4,12 @@ export const shapeCenterX = (shape) => shape.position().x + (shape.size().width
 export const shapeCenterY = (shape) => shape.position().y + (shape.size().height / 2);
 export const shapeRight = (shape) => shape.position().x + shape.size().width;
 export const shapeTop = (shape) => shape.position().y;
+export const shapeArea = (shape) => hasPositionAndSizeAttribs(shape)
+  ? shape.size().width * shape.size().height()
+  : 0;
+
+export const hasPositionAndSizeAttribs = (shape) => {
+  return Boolean(shape
+    && shape.position
+    && shape.size);
+};
diff --git a/src/components/nodes/utilities/shapeMovement.js b/src/components/nodes/utilities/shapeMovement.js
index 5c439432..a73acaf2 100644
--- a/src/components/nodes/utilities/shapeMovement.js
+++ b/src/components/nodes/utilities/shapeMovement.js
@@ -1,4 +1,5 @@
 import {
+  hasPositionAndSizeAttribs,
   shapeBottom,
   shapeCenterX,
   shapeCenterY,
@@ -6,14 +7,24 @@ import {
   shapeRight,
   shapeTop,
 } from '@/components/nodes/utilities/shapeMetrics';
-
-export function isProgrammaticallyMovable(shape) {
-  const programaticallyImmovable = [
-    'processmaker.modeler.bpmn.pool',
-    'PoolLane',
-    'processmaker.components.nodes.boundaryEvent.Shape',
-  ];
-  return shape || !programaticallyImmovable.includes(shape.get('type'));
+import { shapeTypes } from '../../../../tests/e2e/support/constants';
+
+const PROGRAMMATICALLY_IMMOVABLE_SHAPES = [
+  shapeTypes.boundaryEvent,
+  shapeTypes.poolLane,
+];
+
+/**
+ * Shapes we can safely call translate() on.
+ */
+export const canMoveProgrammatically = (shape) => !PROGRAMMATICALLY_IMMOVABLE_SHAPES.includes(shape.get('type'));
+
+/**
+ * Things like flow lines can still be moved programmatically (e.g. with
+ * arrow keys) - but should not be programmatically aligned.
+ */
+export function canAlign(shape) {
+  return hasPositionAndSizeAttribs(shape) && canMoveProgrammatically(shape);
 }
 
 export function moveShapeBottomTo(shape, Y) {
diff --git a/src/components/paperManager.js b/src/components/paperManager.js
index d6aa9c34..7b91f851 100644
--- a/src/components/paperManager.js
+++ b/src/components/paperManager.js
@@ -1,5 +1,6 @@
 import { dia, util } from 'jointjs';
 import { defaultNodeColor, invalidNodeColor } from '@/components/nodeColors';
+import { gridSize } from '@/graph';
 
 export default class PaperManager {
   #paper;
@@ -9,7 +10,7 @@ export default class PaperManager {
     this.#paper = paper;
   }
 
-  static gridSize = 10;
+  static gridSize = gridSize;
 
   static factory(element, interactiveFunc, model) {
     const defaultPadding = 3;
@@ -50,6 +51,10 @@ export default class PaperManager {
     return Math.round(number / PaperManager.gridSize) * PaperManager.gridSize;
   }
 
+  ceilToNearestGridMultiple(number) {
+    return Math.ceil(number / PaperManager.gridSize) * PaperManager.gridSize;
+  }
+
   translate(x, y) {
     if (this.preventTranslate) {
       return;
diff --git a/src/components/toolbar/ToolBar.vue b/src/components/toolbar/ToolBar.vue
index 89e87c1b..93a0b6b3 100644
--- a/src/components/toolbar/ToolBar.vue
+++ b/src/components/toolbar/ToolBar.vue
@@ -5,6 +5,8 @@
     >
       <breadcrumb :breadcrumb-data="breadcrumbData" />
       <div class="mr-3">
+        <align-buttons @save-state="$emit('save-state')" />
+
         <div class="btn-group btn-group-sm mr-2" role="group" aria-label="Undo/redo controls">
           <b-button
             class="btn btn-sm btn-secondary btn-undo"
@@ -98,13 +100,23 @@
 </template>
 <script>
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
-import { faCompress, faExpand, faMapMarked, faMinus, faPlus, faRedo, faSave, faUndo } from '@fortawesome/free-solid-svg-icons';
+import {
+  faCompress,
+  faExpand,
+  faMapMarked,
+  faMinus,
+  faPlus,
+  faRedo,
+  faSave,
+  faUndo,
+} from '@fortawesome/free-solid-svg-icons';
 import undoRedoStore from '@/undoRedoStore';
 import Breadcrumb from '@/components/toolbar/breadcrumb/Breadcrumb';
+import AlignButtons from '@/components/toolbar/alignButtons/AlignButtons';
 
 export default {
   name: 'tool-bar',
-  components: { Breadcrumb, FontAwesomeIcon },
+  components: { Breadcrumb, FontAwesomeIcon, AlignButtons },
   props: {
     canvasDragPosition: {},
     cursor: {},
diff --git a/src/components/toolbar/alignButtons/AlignButtons.vue b/src/components/toolbar/alignButtons/AlignButtons.vue
new file mode 100644
index 00000000..2019dc17
--- /dev/null
+++ b/src/components/toolbar/alignButtons/AlignButtons.vue
@@ -0,0 +1,139 @@
+<template>
+  <div class="btn-group btn-group-sm mr-2" role="group">
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-left"
+      data-test="align-left"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.left"
+      :title="$t('Align Left')"
+      @click="undoableAction(selectedShapes.align.left)"
+    >
+      <font-awesome-icon :icon="alignLeftIcon" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-center"
+      data-test="align-center"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.horizontalCenter"
+      :title="$t('Center Horizontally')"
+      @click="undoableAction(selectedShapes.align.horizontalCenter)"
+    >
+      <font-awesome-icon :icon="alignCenterIcon" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-right mr-1"
+      data-test="align-right"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.right"
+      :title="$t('Align Right')"
+      @click="undoableAction(selectedShapes.align.right)"
+    >
+      <font-awesome-icon :icon="alignRightIcon" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-bottom"
+      data-test="align-bottom"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.bottom"
+      :title="$t('Align Bottom')"
+      @click="undoableAction(selectedShapes.align.bottom)"
+    >
+      <font-awesome-icon :icon="alignRightIcon" class="rotate-90-degrees-right" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-vertical-center"
+      data-test="align-vertical-center"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.verticalCenter"
+      :title="$t('Center Vertically')"
+      @click="undoableAction(selectedShapes.align.verticalCenter)"
+    >
+      <font-awesome-icon :icon="alignCenterIcon" class="rotate-90-degrees-right" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-align-top mr-1"
+      data-test="align-top"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.align.top"
+      :title="$t('Align Top')"
+      @click="undoableAction(selectedShapes.align.top)"
+    >
+      <font-awesome-icon :icon="alignLeftIcon" class="rotate-90-degrees-right" />
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-distribute-horizontal d-flex align-items-center"
+      data-test="distribute-horizontal"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.distribute.horizontally"
+      :title="$t('Distribute Horizontally')"
+      @click="undoableAction(selectedShapes.distribute.horizontally)"
+    >
+      <img
+        :src="distHIcon"
+        alt=""
+        height="16"
+      >
+    </b-button>
+
+    <b-button
+      class="btn btn-sm btn-secondary btn-distribute-vertical d-flex align-items-center"
+      data-test="distribute-vertical"
+      v-b-tooltip.hover
+      :disabled="!selectedShapes.can.distribute.vertically"
+      :title="$t('Distribute Vertically')"
+      @click="undoableAction(selectedShapes.distribute.vertically)"
+    >
+      <img
+        :src="distVIcon"
+        alt=""
+        height="16"
+      >
+    </b-button>
+  </div>
+</template>
+<script>
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
+import { faAlignCenter, faAlignLeft, faAlignRight } from '@fortawesome/free-solid-svg-icons';
+import store from '@/store';
+import { getShapesOptions } from '@/components/nodes/utilities/shapeGroup';
+import distributeHorizontallyIcon from '@/assets/distribute-horizontally-icon.svg';
+import distributeVerticallyIcon from '@/assets/distribute-vertically-icon.svg';
+
+
+export default {
+  components: { FontAwesomeIcon },
+  data() {
+    return {
+      alignCenterIcon: faAlignCenter,
+      alignLeftIcon: faAlignLeft,
+      alignRightIcon: faAlignRight,
+      distHIcon: distributeHorizontallyIcon,
+      distVIcon: distributeVerticallyIcon,
+    };
+  },
+  methods: {
+    undoableAction(actionFn) {
+      actionFn();
+      this.$emit('save-state');
+    },
+  },
+  computed: {
+    selectedShapes() {
+      return getShapesOptions(store.getters.highlightedShapes);
+    },
+  },
+  watch: {
+    selectedShapes() {
+      this.$root.$emit('bv::hide::tooltip');
+    },
+  },
+};
+</script>
+
+<style lang="scss" src="./alignButtons.scss" />
diff --git a/src/components/toolbar/alignButtons/alignButtons.scss b/src/components/toolbar/alignButtons/alignButtons.scss
new file mode 100644
index 00000000..0753cf04
--- /dev/null
+++ b/src/components/toolbar/alignButtons/alignButtons.scss
@@ -0,0 +1,3 @@
+.rotate-90-degrees-right {
+  transform: rotate(90deg);
+}
diff --git a/src/graph.js b/src/graph.js
new file mode 100644
index 00000000..f4765c15
--- /dev/null
+++ b/src/graph.js
@@ -0,0 +1,1 @@
+export const gridSize = 10;


Test patch:
diff --git a/tests/e2e/specs/MessageFlows.spec.js b/tests/e2e/specs/MessageFlows.spec.js
index 26df0da3..a7e74af1 100644
--- a/tests/e2e/specs/MessageFlows.spec.js
+++ b/tests/e2e/specs/MessageFlows.spec.js
@@ -1,4 +1,7 @@
 import {
+  addNodeTypeToPaper,
+  assertDownloadedXmlContainsExpected,
+  assertDownloadedXmlDoesNotContainExpected,
   connectNodesWithFlow,
   dragFromSourceToDest,
   getCrownButtonForElement,
@@ -8,7 +11,7 @@ import {
   isElementCovered,
   modalConfirm,
   moveElement,
-  removeIndentationAndLinebreaks,
+  setBoundaryEvent,
   waitToRenderAllShapes,
 } from '../support/utils';
 import { nodeTypes } from '../support/constants';
@@ -46,7 +49,14 @@ describe('Message Flows', () => {
     dragFromSourceToDest(nodeTypes.task, taskPosition);
 
     const startEventPosition = { x: 150, y: 150 };
-    connectNodesWithFlow('message-flow-button', startEventPosition, taskPosition);
+    getElementAtPosition(startEventPosition, nodeTypes.startEvent)
+      .click()
+      .then($startEvent => {
+        getCrownButtonForElement($startEvent, 'delete-button').click();
+      });
+    addNodeTypeToPaper(startEventPosition, nodeTypes.startEvent, 'switch-to-message-start-event');
+
+    connectNodesWithFlow('message-flow-button', taskPosition, startEventPosition);
 
     const numberOfMessageFlowsAdded = 1;
     getElementAtPosition(taskPosition)
@@ -79,6 +89,12 @@ describe('Message Flows', () => {
 
   it('Cannot connect to itself', () => {
     const startEventPosition = { x: 150, y: 150 };
+    getElementAtPosition(startEventPosition, nodeTypes.startEvent)
+      .click()
+      .then($startEvent => {
+        getCrownButtonForElement($startEvent, 'delete-button').click();
+      });
+    addNodeTypeToPaper(startEventPosition, nodeTypes.endEvent, 'switch-to-message-end-event');
 
     const poolPosition = { x: 100, y: 150 };
     dragFromSourceToDest(nodeTypes.pool, poolPosition);
@@ -105,6 +121,12 @@ describe('Message Flows', () => {
 
   it('Cannot connect to invalid message flow targets', () => {
     const startEventPosition = { x: 150, y: 150 };
+    getElementAtPosition(startEventPosition, nodeTypes.startEvent)
+      .click()
+      .then($startEvent => {
+        getCrownButtonForElement($startEvent, 'delete-button').click();
+      });
+    addNodeTypeToPaper(startEventPosition, nodeTypes.endEvent, 'switch-to-message-end-event');
 
     const poolPosition = { x: 100, y: 150 };
     dragFromSourceToDest(nodeTypes.pool, poolPosition);
@@ -151,7 +173,13 @@ describe('Message Flows', () => {
     dragFromSourceToDest(nodeTypes.task, taskPosition);
 
     const startEventPosition = { x: 150, y: 150 };
-    connectNodesWithFlow('message-flow-button', startEventPosition, taskPosition);
+    getElementAtPosition(startEventPosition, nodeTypes.startEvent)
+      .click()
+      .then($startEvent => {
+        getCrownButtonForElement($startEvent, 'delete-button').click();
+      });
+    addNodeTypeToPaper(startEventPosition, nodeTypes.startEvent, 'switch-to-message-start-event');
+    connectNodesWithFlow('message-flow-button', taskPosition, startEventPosition);
 
     getElementAtPosition(startEventPosition)
       .then(getLinksConnectedToElement)
@@ -177,7 +205,12 @@ describe('Message Flows', () => {
     const taskPosition = { x: pool2Position.x + offset, y: pool2Position.y + offset };
     let numberOfMessageFlowsAdded = 1;
 
-    const messageFlow = '<bpmn:messageFlow id="node_5" name="" sourceRef="node_1" targetRef="node_4"';
+    getElementAtPosition(startEventPosition, nodeTypes.startEvent)
+      .click()
+      .then($startEvent => {
+        getCrownButtonForElement($startEvent, 'delete-button').click();
+      });
+    addNodeTypeToPaper(startEventPosition, nodeTypes.endEvent, 'switch-to-message-end-event');
 
     dragFromSourceToDest(nodeTypes.pool, pool1Position);
     dragFromSourceToDest(nodeTypes.pool, pool2Position);
@@ -189,10 +222,8 @@ describe('Message Flows', () => {
       expect($links.length).to.eq(numberOfMessageFlowsAdded);
     });
 
-    cy.get('[data-test=downloadXMLBtn]').click();
-    cy.window().its('xml').then(removeIndentationAndLinebreaks).then(xml => {
-      expect(xml).to.contain(messageFlow);
-    });
+    const messageFlow = 'bpmn:messageFlow';
+    assertDownloadedXmlContainsExpected(messageFlow);
 
     waitToRenderAllShapes();
 
@@ -206,9 +237,36 @@ describe('Message Flows', () => {
       expect($links.length).to.eq(--numberOfMessageFlowsAdded);
     });
 
-    cy.get('[data-test=downloadXMLBtn]').click();
-    cy.window().its('xml').then(removeIndentationAndLinebreaks).then(xml => {
-      expect(xml).to.not.contain(messageFlow);
-    });
+    assertDownloadedXmlDoesNotContainExpected(messageFlow);
+  });
+
+  it('should connect message end event to boundary message event in different pools', () => {
+    const startEventPosition = { x: 150, y: 150 };
+    const pool1Position = { x: 250, y: 250 };
+    const pool2Position = { x: 250, y: 500 };
+    const offset = 100;
+    const taskPosition = { x: pool2Position.x + offset, y: pool2Position.y + offset };
+    const boundaryEventPosition = { x: taskPosition.x + 58, y: taskPosition.y };
+
+    getElementAtPosition(startEventPosition, nodeTypes.startEvent)
+      .click()
+      .then($startEvent => {
+        getCrownButtonForElement($startEvent, 'delete-button').click();
+      });
+    addNodeTypeToPaper(startEventPosition, nodeTypes.endEvent, 'switch-to-message-end-event');
+
+    dragFromSourceToDest(nodeTypes.pool, pool1Position);
+    dragFromSourceToDest(nodeTypes.pool, pool2Position);
+    addNodeTypeToPaper(taskPosition, nodeTypes.task, 'switch-to-sub-process');
+    setBoundaryEvent(nodeTypes.boundaryMessageEvent, taskPosition, nodeTypes.subProcess);
+    connectNodesWithFlow('message-flow-button', startEventPosition, boundaryEventPosition, 'center');
+
+    const endEventId = 'node_3';
+    const boundaryEventId = 'node_8';
+    const endEventXml = `<bpmn:endEvent id="${endEventId}" name="Message End Event">`;
+    const boundaryEventXml = `<bpmn:boundaryEvent id="${boundaryEventId}" name="Boundary Message Event" attachedToRef="node_7">`;
+    const messageFlowXml = `<bpmn:messageFlow id="node_9" name="" sourceRef="${endEventId}" targetRef="${boundaryEventId}" />`;
+
+    assertDownloadedXmlContainsExpected(endEventXml, boundaryEventXml, messageFlowXml);
   });
 });
diff --git a/tests/e2e/support/constants.js b/tests/e2e/support/constants.js
index ad7068fb..7c4e883d 100644
--- a/tests/e2e/support/constants.js
+++ b/tests/e2e/support/constants.js
@@ -7,6 +7,11 @@ export const defaultViewportDimensions = {
   height: 768,
 };
 
+/**
+ * Node types are strings returned by
+ * shape.component.node.type - they are not the string
+ * same as the shape type.
+ */
 export const nodeTypes = {
   startEvent: 'processmaker-modeler-start-event',
   messageStartEvent: 'processmaker-modeler-message-start-event',
@@ -39,6 +44,14 @@ export const nodeTypes = {
   poolLane: 'processmaker-modeler-lane',
 };
 
+/**
+ * Shape types are returned by shape.get('type').
+ */
+export const shapeTypes = {
+  poolLane: 'PoolLane',
+  boundaryEvent: 'processmaker.components.nodes.boundaryEvent.Shape',
+};
+
 export const boundaryEventSelector = '.main-paper ' +
   '[data-type="processmaker.components.nodes.boundaryEvent.Shape"]';
 
diff --git a/tests/e2e/support/utils.js b/tests/e2e/support/utils.js
index 7d29180f..1a3831f6 100644
--- a/tests/e2e/support/utils.js
+++ b/tests/e2e/support/utils.js
@@ -1,6 +1,7 @@
 import { saveDebounce } from '../../../src/components/inspectors/inspectorConstants';
 import path from 'path';
 import { boundaryEventSelector, nodeTypes, taskSelector } from './constants';
+import { gridSize } from '../../../src/graph';
 
 const renderTime = 300;
 
@@ -23,7 +24,7 @@ export function getGraphElements() {
 }
 
 export function getElementAtPosition(position, componentType) {
-  const paperGridSize = 10;
+  const paperGridSize = gridSize;
   const searchRectangle = {
     width: paperGridSize,
     height: paperGridSize,
diff --git a/tests/unit/components/nodes/utilities/distributeShapes.spec.js b/tests/unit/components/nodes/utilities/distributeShapes.spec.js
index be076825..c95b680d 100644
--- a/tests/unit/components/nodes/utilities/distributeShapes.spec.js
+++ b/tests/unit/components/nodes/utilities/distributeShapes.spec.js
@@ -1,5 +1,4 @@
 import {
-  distributeHorizontalCentersEvenly,
   distributeHorizontalSpacingEvenly,
   distributeVerticalCentersEvenly,
 } from '@/components/nodes/utilities/distribute';
@@ -14,19 +13,19 @@ const expect = require('expect');
 expect.extend({ toHaveBeenProgrammaticallyMoved, toHaveBeenProgrammaticallyMovedBy });
 
 describe('Shape Distribution', () => {
-  it('can distribute vertical centers', () => {
+  it('can distribute vertical spacing', () => {
     const shapes = [
       dummyShape(0, 0, 100, 50),
       dummyShape(100, 50, 100, 50),
-      dummyShape(100, 100, 100, 50),
-      dummyShape(200, 600, 100, 50),
+      dummyShape(100, 100, 100, 100),
+      dummyShape(200, 750, 100, 50),
     ];
 
     distributeVerticalCentersEvenly(shapes);
 
     expect(shapes[0]).not.toHaveBeenProgrammaticallyMoved();
-    expect(shapes[1]).toHaveBeenProgrammaticallyMovedBy(0, 150);
-    expect(shapes[2]).toHaveBeenProgrammaticallyMovedBy(0, 300);
+    expect(shapes[1]).toHaveBeenProgrammaticallyMovedBy(0, 200);
+    expect(shapes[2]).toHaveBeenProgrammaticallyMovedBy(0, 375);
     expect(shapes[3]).not.toHaveBeenProgrammaticallyMoved();
   });
 
@@ -38,29 +37,12 @@ describe('Shape Distribution', () => {
     ];
 
     distributeVerticalCentersEvenly(shapes);
-    distributeHorizontalCentersEvenly(shapes);
     distributeHorizontalSpacingEvenly(shapes);
 
     expect(shapes[0]).not.toHaveBeenProgrammaticallyMoved();
     expect(shapes[1]).not.toHaveBeenProgrammaticallyMoved();
   });
 
-  it('can distribute horizontal centers', () => {
-    const shapes = [
-      dummyShape(0, 0, 100, 50),
-      dummyShape(100, 0, 50, 50),
-      dummyShape(150, 0, 100, 50),
-      dummyShape(300, 0, 100, 50),
-    ];
-
-    distributeHorizontalCentersEvenly(shapes);
-
-    expect(shapes[0]).not.toHaveBeenProgrammaticallyMoved();
-    expect(shapes[1]).toHaveBeenProgrammaticallyMovedBy(25, 0);
-    expect(shapes[2]).toHaveBeenProgrammaticallyMovedBy(50, 0);
-    expect(shapes[3]).not.toHaveBeenProgrammaticallyMoved();
-  });
-
   it('can distribute horizontal spacing', () => {
     const shapes = [
       dummyShape(0, 0, 100, 50),
diff --git a/tests/unit/components/nodes/utilities/shapeGroup.spec.js b/tests/unit/components/nodes/utilities/shapeGroup.spec.js
index d3e8bbdd..844f9a0d 100644
--- a/tests/unit/components/nodes/utilities/shapeGroup.spec.js
+++ b/tests/unit/components/nodes/utilities/shapeGroup.spec.js
@@ -1,7 +1,22 @@
 import { dummyShape } from '../../../utilities/dummies';
-import { getBoundingBox } from '@/components/nodes/utilities/shapeGroup';
+import { getBoundingBox, getShapesOptions } from '@/components/nodes/utilities/shapeGroup';
+import { shapeTypes } from '../../../../e2e/support/constants';
+import { canAlign } from '@/components/nodes/utilities/shapeMovement';
+
+describe('bounding box metrics', () => {
+  it('handles non-shape objects correctly without breaking', () => {
+    const bounds = getBoundingBox({});
+
+    expect(bounds.left).toBe(0);
+    expect(bounds.top).toBe(0);
+    expect(bounds.bottom).toBe(0);
+    expect(bounds.right).toBe(0);
+    expect(bounds.width).toBe(0);
+    expect(bounds.height).toBe(0);
+    expect(bounds.vMiddle).toBe(0);
+    expect(bounds.hMiddle).toBe(0);
+  });
 
-describe('shape group', () => {
   it('can calculate a correct bounding box with one shape', () => {
     const shapes = [
       dummyShape(0, 0, 100, 50),
@@ -37,3 +52,189 @@ describe('shape group', () => {
     expect(bounds.hMiddle).toBe(150);
   });
 });
+
+describe('shape group alignment options', () => {
+  it('cannot align when no shapes are selected', () => {
+    expect(getShapesOptions([]).can.align.left).toBe(false);
+    expect(getShapesOptions([]).can.align.horizontalCenter).toBe(false);
+    expect(getShapesOptions([]).can.align.right).toBe(false);
+    expect(getShapesOptions([]).can.align.bottom).toBe(false);
+    expect(getShapesOptions([]).can.align.verticalCenter).toBe(false);
+    expect(getShapesOptions([]).can.align.top).toBe(false);
+  });
+
+  it('cannot align when there are too few shapes selected', () => {
+    const shapes = [dummyShape(0, 0, 100, 50)];
+    expect(getShapesOptions(shapes).can.align.left).toBe(false);
+    expect(getShapesOptions(shapes).can.align.horizontalCenter).toBe(false);
+    expect(getShapesOptions(shapes).can.align.right).toBe(false);
+    expect(getShapesOptions(shapes).can.align.bottom).toBe(false);
+    expect(getShapesOptions(shapes).can.align.verticalCenter).toBe(false);
+    expect(getShapesOptions(shapes).can.align.top).toBe(false);
+  });
+
+  it('cannot align left when shapes are already left-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.left).toBe(false);
+  });
+
+  it('can align left when shapes are not left-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(20, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.left).toBe(true);
+  });
+
+
+  it('cannot align right when shapes are already right-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(30, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.right).toBe(false);
+  });
+
+  it('can align right when shapes are not right-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.right).toBe(true);
+  });
+
+  it('cannot align horizontal-center when shapes are already horizontal-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(15, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.horizontalCenter).toBe(false);
+  });
+
+  it('can align horizontal-center when shapes are not horizontal-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.horizontalCenter).toBe(true);
+  });
+
+  it('cannot align right when shapes are already bottom-aligned', () => {
+    const shapes = [
+      dummyShape(0, 20, 100, 30),
+      dummyShape(0, 0, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.bottom).toBe(false);
+  });
+
+  it('can align bottom when shapes are not bottom-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 10, 70, 30),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.bottom).toBe(true);
+  });
+
+
+  it('cannot align right when shapes are already top-aligned', () => {
+    const shapes = [
+      dummyShape(0, 20, 100, 30),
+      dummyShape(0, 20, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.top).toBe(false);
+  });
+
+  it('can align top when shapes are not top-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 10, 70, 30),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.top).toBe(true);
+  });
+
+  it('cannot align vertical-center when shapes are already vertical-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 10, 70, 30),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.verticalCenter).toBe(false);
+  });
+
+  it('can align vertical-center when shapes are not vertical-center-aligned', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(0, 20, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.align.verticalCenter).toBe(true);
+  });
+
+  it('cannot align dimensionless shapes', () => {
+    expect(canAlign({})).toBe(false);
+  });
+
+  it('cannot align poolLanes', () => {
+    const poolLane = dummyShape(0, 0, 100, 50);
+    poolLane.get = () => shapeTypes.poolLane;
+
+    expect(canAlign(poolLane)).toBe(false);
+  });
+
+  it('cannot align boundaryEvents', () => {
+    const boundaryEvent = dummyShape(0, 0, 100, 50);
+    boundaryEvent.get = () => shapeTypes.boundaryEvent;
+
+    expect(canAlign(boundaryEvent)).toBe(false);
+  });
+});
+
+
+describe('shape group distribution options', () => {
+  it('cannot distribute when no shapes are selected', () => {
+    expect(getShapesOptions([]).can.distribute.horizontally).toBe(false);
+    expect(getShapesOptions([]).can.distribute.vertically).toBe(false);
+  });
+
+  it('cannot distribute when there are too few shapes selected', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(100, 100, 100, 50),
+    ];
+    expect(getShapesOptions(shapes).can.distribute.horizontally).toBe(false);
+    expect(getShapesOptions(shapes).can.distribute.vertically).toBe(false);
+  });
+
+  it('can horizontally distribute three or more items', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(100, 100, 70, 50),
+      dummyShape(200, 200, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.distribute.horizontally).toBe(true);
+  });
+
+  it('can vertically distribute three or more items', () => {
+    const shapes = [
+      dummyShape(0, 0, 100, 50),
+      dummyShape(100, 100, 70, 50),
+      dummyShape(200, 200, 70, 50),
+    ];
+
+    expect(getShapesOptions(shapes).can.distribute.horizontally).toBe(true);
+  });
+});
diff --git a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js
index 6dd9ded8..e9e04ef9 100644
--- a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js
+++ b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMoved/index.js
@@ -1,22 +1,22 @@
 import get from 'lodash/get';
 import passes from './predicate.js';
 
-const failMessage = (shape) => () => {
-  return this.utils.matcherHint('.toHaveBeenProgrammaticallyMoved', 'shape', '') +
+const failMessage = (shape, utils) => () => {
+  return utils.matcherHint('.toHaveBeenProgrammaticallyMoved', 'shape', '') +
     '\n\nExpected:' +
     '\n Shape to have been programmatically moved.' +
     '\n\nReceived: ' +
     (get(shape, 'translate.mock.calls', []).length === 0
       ? '\n Shape has not been moved.'
-      : `\n Move has been called, but with dx==0, dy==0: ${this.utils.printReceived(shape.translate.mock.calls)}`);
+      : `\n Move has been called, but with dx==0, dy==0: ${utils.printReceived(shape.translate.mock.calls)}`);
 };
 
-const passMessage = (shape) => () => {
+const passMessage = (shape, utils) => () => {
   return this.utils.matcherHint('.not.toHaveBeenProgrammaticallyMoved', 'shape', '') +
     '\n\nExpected:' +
     '\n Shape to not have been programmatically moved.' +
     '\n\nReceived: ' +
-    `\n Shape has been moved by: ${this.utils.printReceived(shape.translate.mock.calls)}`;
+    `\n Shape has been moved by: ${utils.printReceived(shape.translate.mock.calls)}`;
 };
 
 /**
@@ -29,8 +29,8 @@ export default function toHaveBeenProgrammaticallyMoved(shape) {
   }
 
   if (passes(shape)) {
-    return { pass: true, message: passMessage(shape) };
+    return { pass: true, message: passMessage(shape, this.utils) };
   }
 
-  return { pass: false, message: failMessage(shape) };
+  return { pass: false, message: failMessage(shape, this.utils) };
 }
diff --git a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js
index 1ef6c4a7..7cbf5b14 100644
--- a/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js
+++ b/tests/unit/customMatchers/toHaveBeenProgrammaticallyMovedBy/index.js
@@ -1,23 +1,23 @@
 import passes from './predicate';
 
-const failMessage = (shape, dx, dy) => () => {
+const failMessage = (shape, dx, dy, utils) => () => {
   const expected = [dx, dy];
-  return this.utils.matcherHint('.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
+  return utils.matcherHint('.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
     '\n\n' +
-    `Expected:\n Shape to have been programmatically moved by ${this.utils.printReceived(expected)}.` +
+    `Expected:\n Shape to have been programmatically moved by ${utils.printReceived(expected)}.` +
     '\n\nReceived: ' +
     (shape.translate.mock.calls.length === 0
       ? '\n Shape has not been moved.'
-      : `\n Shape has been moved by: ${this.utils.printReceived(shape.translate.mock.calls)}`);
+      : `\n Shape has been moved by: ${utils.printReceived(shape.translate.mock.calls)}`);
 };
 
-const passMessage = (shape, dx, dy) => () => {
+const passMessage = (shape, dx, dy, utils) => () => {
   const expected = [dx, dy];
-  return this.utils.matcherHint('.not.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
+  return utils.matcherHint('.not.toHaveBeenProgrammaticallyMovedBy', 'shape', '') +
     '\n\n' +
-    `Expected:\n Shape to not have been programmatically moved by ${this.utils.printReceived(expected)}.` +
+    `Expected:\n Shape to not have been programmatically moved by ${utils.printReceived(expected)}.` +
     '\n\nReceived: ' +
-    `\n Shape has been moved by: ${this.utils.printReceived(shape.translate.mock.calls)}`;
+    `\n Shape has been moved by: ${utils.printReceived(shape.translate.mock.calls)}`;
 };
 
 /**
@@ -32,8 +32,8 @@ export default function toHaveBeenProgrammaticallyMovedBy(shape, dx, dy) {
   }
 
   if (passes(shape, dx, dy)) {
-    return { pass: true, message: passMessage(shape, dx, dy) };
+    return { pass: true, message: passMessage(shape, dx, dy, this.utils) };
   }
 
-  return { pass: false, message: failMessage(shape, dx, dy) };
+  return { pass: false, message: failMessage(shape, dx, dy, this.utils) };
 }
diff --git a/tests/unit/utilities/dummies.js b/tests/unit/utilities/dummies.js
index 2de3cd56..f0e82b12 100644
--- a/tests/unit/utilities/dummies.js
+++ b/tests/unit/utilities/dummies.js
@@ -6,6 +6,7 @@ export function dummyShape(x, y, width, height) {
     size: () => {
       return { width, height };
     },
+    get: () => 'dummy',
     translate: jest.fn(),
   };
 }