Repository: ProcessMaker/modeler
Pull request #1124 (2020-02-19 18:15:52)

Move download button to validation footer
Related issue: #1120.

**Note:** Another PR is still required in the processmaker core repo to update the container `ModelerApp.vue` component there as well.


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/ModelerApp.vue b/src/ModelerApp.vue
index 36957acf..9dc17aeb 100644
--- a/src/ModelerApp.vue
+++ b/src/ModelerApp.vue
@@ -9,19 +9,19 @@
 
         <div class="ml-auto">
           <b-btn variant="secondary" size="sm" v-b-modal="'uploadmodal'" class="mr-2">
-            <i class="fas fa-upload mr-1" />
+            <i class="fas fa-upload mr-1"/>
             {{ $t('Upload XML') }}
           </b-btn>
-          <b-btn variant="secondary" size="sm" data-test="downloadXMLBtn" @click="download">
-            <i class="fas fa-download mr-1" />
-            {{ $t('Download XML') }}
-          </b-btn>
         </div>
       </b-card-header>
       <b-card-body class="overflow-hidden position-relative p-0 vh-100">
-        <modeler ref="modeler" @validate="validationErrors = $event" @warnings="warnings = $event" />
+        <modeler ref="modeler" @set-xml-manager="xmlManager = $event" @validate="validationErrors = $event" @warnings="warnings = $event"/>
       </b-card-body>
-      <validation-status :validation-errors="validationErrors" :warnings="warnings" />
+      <validation-status
+        :validation-errors="validationErrors"
+        :warnings="warnings"
+        :xml-manager="xmlManager"
+      />
     </b-card>
 
     <b-modal
@@ -48,9 +48,7 @@
 <script>
 import Modeler from './components/modeler/Modeler.vue';
 import FileUpload from 'vue-upload-component';
-import FilerSaver from 'file-saver';
 import ValidationStatus from '@/components/validationStatus/ValidationStatus';
-import runningInCypressTest from '@/runningInCypressTest';
 
 const reader = new FileReader();
 
@@ -67,6 +65,7 @@ export default {
       uploadedXml: null,
       xmlFile: [],
       warnings: [],
+      xmlManager: null,
     };
   },
   watch: {
@@ -77,21 +76,6 @@ export default {
     },
   },
   methods: {
-    download() {
-      this.$refs.modeler.toXML((err, xml) => {
-        if (err) {
-          alert(err);
-        } else {
-          if (runningInCypressTest()) {
-            /* Save XML string to window–this is used in testing to compare against known valid XML */
-            window.xml = xml;
-            return;
-          }
-          let file = new File([xml], 'bpmnProcess.xml', { type: 'text/xml' });
-          FilerSaver.saveAs(file);
-        }
-      });
-    },
     loadXmlIntoModeler() {
       this.$refs.modeler.loadXML(this.uploadedXml);
     },
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/crown/crownConfig/crownConfig.vue b/src/components/crown/crownConfig/crownConfig.vue
index 249f0601..731cab51 100644
--- a/src/components/crown/crownConfig/crownConfig.vue
+++ b/src/components/crown/crownConfig/crownConfig.vue
@@ -102,8 +102,10 @@ export default {
   },
   mixins: [poolLaneCrownConfig],
   watch: {
+    highlightedShapes() {
+      this.showCrown = this.highlightedShapes[0] === this.shape;
+    },
     highlighted(highlighted) {
-      this.showCrown = highlighted;
       if (!highlighted) {
         this.taskDropdownInitiallyOpen = false;
       }
diff --git a/src/components/modeler/Modeler.vue b/src/components/modeler/Modeler.vue
index 1bcb9efc..8d63d72c 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
@@ -130,8 +131,7 @@ import Node from '@/components/nodes/node';
 import { addNodeToProcess } from '@/components/nodeManager';
 import moveShapeByKeypress from '@/components/modeler/moveWithArrowKeys';
 import setUpSelectionBox from '@/components/modeler/setUpSelectionBox';
-
-const version = '1.0';
+import XMLManager from '@/components/modeler/XMLManager';
 
 export default {
   components: {
@@ -180,6 +180,7 @@ export default {
       nodeTypes: [],
       breadcrumbData: [],
       activeNode: null,
+      xmlManager: null,
     };
   },
   watch: {
@@ -563,18 +564,12 @@ export default {
       this.isRendering = false;
       this.$emit('parsed');
     },
-    loadXML(xml = this.currentXML) {
-      this.moddle.fromXML(xml, (err, definitions) => {
-        if (err) {
-          return;
-        }
-        definitions.exporter = 'ProcessMaker Modeler';
-        definitions.exporterVersion = version;
-        this.definitions = definitions;
-        this.nodeIdGenerator = new NodeIdGenerator(definitions);
-        store.commit('clearNodes');
-        this.renderPaper();
-      });
+    async loadXML(xml = this.currentXML) {
+      this.definitions = await this.xmlManager.getDefinitionsFromXml(xml);
+      this.xmlManager.definitions = this.definitions;
+      this.nodeIdGenerator = new NodeIdGenerator(this.definitions);
+      store.commit('clearNodes');
+      this.renderPaper();
     },
     getBoundaryEvents(process) {
       return process.get('flowElements').filter(({ $type }) => $type === 'bpmn:BoundaryEvent');
@@ -740,8 +735,9 @@ export default {
     });
 
     this.moddle = new BpmnModdle(this.extensions);
-
     this.linter = new Linter(linterConfig);
+    this.xmlManager = new XMLManager(this.moddle);
+    this.$emit('set-xml-manager', this.xmlManager);
   },
   mounted() {
     document.addEventListener('keydown', this.keydownListener);
diff --git a/src/components/modeler/XMLManager.js b/src/components/modeler/XMLManager.js
new file mode 100644
index 00000000..51d2b81d
--- /dev/null
+++ b/src/components/modeler/XMLManager.js
@@ -0,0 +1,51 @@
+import runningInCypressTest from '@/runningInCypressTest';
+import FilerSaver from 'file-saver';
+
+export default class XMLManager {
+  #moddle;
+  #definitions;
+
+  constructor(moddle) {
+    this.#moddle = moddle;
+  }
+
+  set definitions(definitions) {
+    this.#definitions = definitions;
+  }
+
+  getDefinitionsFromXml(xmlString) {
+    return new Promise((resolve, reject) => {
+      this.#moddle.fromXML(xmlString, (err, definitions) => {
+        if (err) {
+          reject(err);
+        }
+
+        definitions.exporter = 'ProcessMaker Modeler';
+        definitions.exporterVersion = '1.0';
+
+        resolve(definitions);
+      });
+    });
+  }
+
+  download() {
+    if (!this.#definitions) {
+      return;
+    }
+
+    this.#moddle.toXML(this.#definitions, { format: true }, (err, xml) => {
+      if (err) {
+        alert(err);
+      } else {
+        if (runningInCypressTest()) {
+          /* Save XML string to window–this is used in testing to compare against known valid XML */
+          window.xml = xml;
+          return;
+        }
+
+        const file = new File([xml], 'bpmnProcess.xml', { type: 'text/xml' });
+        FilerSaver.saveAs(file);
+      }
+    });
+  }
+}
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/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/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/components/validationStatus/ValidationStatus.vue b/src/components/validationStatus/ValidationStatus.vue
index d4f3a0bf..cecad9d3 100644
--- a/src/components/validationStatus/ValidationStatus.vue
+++ b/src/components/validationStatus/ValidationStatus.vue
@@ -1,83 +1,83 @@
 <template>
-  <b-card-footer class="p-0 border-0">
-    <div class="statusbar d-flex align-items-center justify-content-end pr-3 border-top">
-      <div>
-        <div class="status-bar-container d-flex align-items-center justify-content-end">
-          <template v-if="autoValidate">
-            <button v-if="numberOfProblemsToDisplay === 0" type="button" class="btn btn-light" :disabled="true">
-              {{ $t('BPMN Valid') }}
-              <span class="badge badge-success badge-pill">
-                <font-awesome-icon :icon="faCheck" />
-              </span>
-            </button>
-            <button v-else type="button" data-test="validation-list-toggle" class="btn btn-light"
-              @click="shouldDisplayProblemsPanel = !shouldDisplayProblemsPanel"
-            >
-              {{ $t('BPMN Issues') }}
-              <span class="badge badge-primary badge-pill">
-                {{ numberOfProblemsToDisplay }}
-              </span>
-              <font-awesome-icon class="ml-3" :icon="chevronIcon" />
-            </button>
-          </template>
+  <b-card-footer class="p-0 border-0 statusbar d-flex align-items-center justify-content-end pr-3 pl-3 border-top">
+    <b-btn
+      class="mr-auto"
+      :disabled="!xmlManager"
+      variant="secondary"
+      size="sm"
+      data-test="downloadXMLBtn"
+      @click="xmlManager && xmlManager.download()"
+    >
+      <i class="fas fa-download mr-1"/>
+      {{ $t('Download BPMN') }}
+    </b-btn>
 
-          <span class="divider" />
+    <div class="status-bar-container d-flex align-items-center justify-content-end">
+      <template v-if="autoValidate">
+        <button v-if="numberOfProblemsToDisplay === 0" type="button" class="btn btn-light" :disabled="true">
+          {{ $t('BPMN Valid') }}
+          <span class="badge badge-success badge-pill">
+            <font-awesome-icon :icon="faCheck"/>
+          </span>
+        </button>
+        <button v-else type="button" data-test="validation-list-toggle" class="btn btn-light"
+          @click="shouldDisplayProblemsPanel = !shouldDisplayProblemsPanel"
+        >
+          {{ $t('BPMN Issues') }}
+          <span class="badge badge-primary badge-pill">
+            {{ numberOfProblemsToDisplay }}
+          </span>
+          <font-awesome-icon class="ml-3" :icon="chevronIcon"/>
+        </button>
+      </template>
 
-          <b-form-checkbox
-            data-test="validation-toggle"
-            class="h-100 d-flex align-items-center"
-            v-model="autoValidate"
-            switch
-          >
-            {{ $t('Auto validate') }}
-          </b-form-checkbox>
+      <span class="divider"/>
 
+      <b-form-checkbox
+        data-test="validation-toggle"
+        class="h-100 d-flex align-items-center"
+        v-model="autoValidate"
+        switch
+      >
+        {{ $t('Auto validate') }}
+      </b-form-checkbox>
 
-          <transition name="slide">
-
-            <div v-if="isProblemsPanelDisplayed" class="validation-container position-absolute text-left">
-              <dl class="validation-container__list align-items-baseline" data-test="validation-list">
-                <template v-for="error in errorList">
-                  <dt class="text-capitalize" :key="error.errorId">
-                    <font-awesome-icon
-                      class="status-bar-container__status-icon ml-1 mr-1 mt-1"
-                      :style="{ color: isErrorCategory(error) ? errorColor : warningColor }"
-                      :icon="isErrorCategory(error) ? faTimesCircle: faExclamationTriangle"
-                    />
-                    {{ error.errorKey }}
-                  </dt>
-                  <dd :key="`${ error.errorId }_dd`">
-                    <p class="pl-4 mb-0 font-italic">{{ error.message }}.</p>
-                    <p class="pl-4 mb-0" v-if="error.id"><span class="font-weight-bold">{{ $t('Node ID') }}:</span> {{
-                      error.id }}</p>
-                  </dd>
-                </template>
-                <template v-for="(warning, index) in warnings">
-                  <dt class="text-capitalize" :key="warning.title + index">
-                    <font-awesome-icon class="status-bar-container__status-icon ml-1 mr-1 mt-1"
-                      :style="{ color: warningColor }" :icon="faExclamationTriangle"
-                    />
-                    {{ warning.title }}
-                  </dt>
-                  <dd :key="warning.title + index + '__dd'" class="font-italic pl-4">{{ warning.text }}</dd>
-                </template>
-              </dl>
-            </div>
-          </transition>
+      <transition name="slide">
+        <div v-if="isProblemsPanelDisplayed" class="validation-container position-absolute text-left">
+          <dl class="validation-container__list align-items-baseline" data-test="validation-list">
+            <template v-for="error in errorList">
+              <dt class="text-capitalize" :key="error.errorId">
+                <font-awesome-icon
+                  class="status-bar-container__status-icon ml-1 mr-1 mt-1"
+                  :style="{ color: isErrorCategory(error) ? errorColor : warningColor }"
+                  :icon="isErrorCategory(error) ? faTimesCircle: faExclamationTriangle"
+                />
+                {{ error.errorKey }}
+              </dt>
+              <dd :key="`${ error.errorId }_dd`">
+                <p class="pl-4 mb-0 font-italic">{{ error.message }}.</p>
+                <p class="pl-4 mb-0" v-if="error.id"><span class="font-weight-bold">{{ $t('Node ID') }}:</span> {{
+                  error.id }}</p>
+              </dd>
+            </template>
+            <template v-for="(warning, index) in warnings">
+              <dt class="text-capitalize" :key="warning.title + index">
+                <font-awesome-icon class="status-bar-container__status-icon ml-1 mr-1 mt-1"
+                  :style="{ color: warningColor }" :icon="faExclamationTriangle"
+                />
+                {{ warning.title }}
+              </dt>
+              <dd :key="warning.title + index + '__dd'" class="font-italic pl-4">{{ warning.text }}</dd>
+            </template>
+          </dl>
         </div>
-      </div>
+      </transition>
     </div>
   </b-card-footer>
 </template>
 
 <script>
-import {
-  faCheck,
-  faChevronDown,
-  faChevronUp,
-  faExclamationTriangle,
-  faTimesCircle,
-} from '@fortawesome/free-solid-svg-icons';
+import { faCheck, faChevronDown, faChevronUp, faExclamationTriangle, faTimesCircle } from '@fortawesome/free-solid-svg-icons';
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
 import store from '@/store';
 import validationErrorList from './errorListUtil';
@@ -86,7 +86,7 @@ export default {
   components: {
     FontAwesomeIcon,
   },
-  props: ['validationErrors', 'warnings'],
+  props: ['validationErrors', 'warnings', 'xmlManager'],
   data() {
     return {
       shouldDisplayProblemsPanel: false,


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/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(),
   };
 }