From 9728b64df09a8f374104f861affcb1563ac850ce Mon Sep 17 00:00:00 2001
From: Bram Wiepjes <bramw@protonmail.com>
Date: Fri, 12 Mar 2021 13:46:22 +0000
Subject: [PATCH] Resolve "Ordering fields per grid view"

---
 .../database/api/views/grid/schemas.py        |   8 +
 .../database/api/views/grid/serializers.py    |   2 +-
 .../0027_gridviewfieldoptions_order.py        |  18 ++
 .../baserow/contrib/database/views/handler.py |   2 +-
 .../baserow/contrib/database/views/models.py  |   3 +
 .../api/views/grid/test_grid_view_views.py    |   6 +
 .../contrib/database/view/test_view_models.py |   2 +
 changelog.md                                  |   1 +
 .../assets/scss/components/views/grid.scss    |  14 +
 .../components/view/grid/GridView.vue         |   2 +
 .../view/grid/GridViewFieldDragging.vue       | 259 ++++++++++++++++++
 .../view/grid/GridViewFieldType.vue           |  11 +
 .../view/grid/GridViewFieldWidthHandle.vue    |   2 +-
 .../components/view/grid/GridViewHead.vue     |   1 +
 .../components/view/grid/GridViewSection.vue  |  61 ++++-
 web-frontend/modules/database/store/field.js  |  12 +-
 .../modules/database/store/view/grid.js       |  47 ++++
 web-frontend/modules/database/viewTypes.js    |  17 ++
 18 files changed, 457 insertions(+), 11 deletions(-)
 create mode 100644 backend/src/baserow/contrib/database/migrations/0027_gridviewfieldoptions_order.py
 create mode 100644 web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue

diff --git a/backend/src/baserow/contrib/database/api/views/grid/schemas.py b/backend/src/baserow/contrib/database/api/views/grid/schemas.py
index 810bdde8a..a51def7b2 100644
--- a/backend/src/baserow/contrib/database/api/views/grid/schemas.py
+++ b/backend/src/baserow/contrib/database/api/views/grid/schemas.py
@@ -17,6 +17,14 @@ grid_view_field_options_schema = {
                     'example': True,
                     'description': 'Whether or not the field should be hidden in the '
                                    'current view.'
+                },
+                'order': {
+                    'type': 'integer',
+                    'example': 0,
+                    'description': 'The position that the field has within the view, '
+                                   'lowest first. If there is another field with the '
+                                   'same order value then the field with the lowest '
+                                   'id must be shown first.'
                 }
             }
         },
diff --git a/backend/src/baserow/contrib/database/api/views/grid/serializers.py b/backend/src/baserow/contrib/database/api/views/grid/serializers.py
index 86daf5f66..1bccb767c 100644
--- a/backend/src/baserow/contrib/database/api/views/grid/serializers.py
+++ b/backend/src/baserow/contrib/database/api/views/grid/serializers.py
@@ -101,7 +101,7 @@ class GridViewSerializer(serializers.ModelSerializer):
 class GridViewFieldOptionsSerializer(serializers.ModelSerializer):
     class Meta:
         model = GridViewFieldOptions
-        fields = ('width', 'hidden')
+        fields = ('width', 'hidden', 'order')
 
 
 class GridViewFilterSerializer(serializers.Serializer):
diff --git a/backend/src/baserow/contrib/database/migrations/0027_gridviewfieldoptions_order.py b/backend/src/baserow/contrib/database/migrations/0027_gridviewfieldoptions_order.py
new file mode 100644
index 000000000..04f711f39
--- /dev/null
+++ b/backend/src/baserow/contrib/database/migrations/0027_gridviewfieldoptions_order.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.11 on 2021-03-09 18:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('database', '0026_auto_20210125_1454'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='gridviewfieldoptions',
+            name='order',
+            field=models.SmallIntegerField(default=32767),
+        ),
+    ]
diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py
index 1267916b3..f0fcd64ce 100644
--- a/backend/src/baserow/contrib/database/views/handler.py
+++ b/backend/src/baserow/contrib/database/views/handler.py
@@ -160,7 +160,7 @@ class ViewHandler:
         :param user: The user on whose behalf the request is made.
         :type user: User
         :param grid_view: The grid view for which the field options need to be updated.
-        :type grid_view: Model
+        :type grid_view: GridView
         :param field_options: A dict with the field ids as the key and a dict
             containing the values that need to be updated as value.
         :type field_options: dict
diff --git a/backend/src/baserow/contrib/database/views/models.py b/backend/src/baserow/contrib/database/views/models.py
index 19a8061a0..36e52ea2a 100644
--- a/backend/src/baserow/contrib/database/views/models.py
+++ b/backend/src/baserow/contrib/database/views/models.py
@@ -159,6 +159,9 @@ class GridViewFieldOptions(models.Model):
     # abstraction in the web-frontend.
     width = models.PositiveIntegerField(default=200)
     hidden = models.BooleanField(default=False)
+    # The default value is the maximum value of the small integer field because a newly
+    # created field must always be last.
+    order = models.SmallIntegerField(default=32767)
 
     class Meta:
         ordering = ('field_id',)
diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py
index 8fdd5638e..18fd57cc8 100644
--- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py
+++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py
@@ -226,8 +226,10 @@ def test_list_rows_include_field_options(api_client, data_fixture):
     assert len(response_json['field_options']) == 2
     assert response_json['field_options'][str(text_field.id)]['width'] == 200
     assert response_json['field_options'][str(text_field.id)]['hidden'] is False
+    assert response_json['field_options'][str(text_field.id)]['order'] == 32767
     assert response_json['field_options'][str(number_field.id)]['width'] == 200
     assert response_json['field_options'][str(number_field.id)]['hidden'] is False
+    assert response_json['field_options'][str(number_field.id)]['order'] == 32767
 
 
 @pytest.mark.django_db
@@ -385,16 +387,20 @@ def test_patch_grid_view(api_client, data_fixture):
     assert len(response_json['field_options']) == 2
     assert response_json['field_options'][str(text_field.id)]['width'] == 300
     assert response_json['field_options'][str(text_field.id)]['hidden'] is True
+    assert response_json['field_options'][str(text_field.id)]['order'] == 32767
     assert response_json['field_options'][str(number_field.id)]['width'] == 200
     assert response_json['field_options'][str(number_field.id)]['hidden'] is False
+    assert response_json['field_options'][str(number_field.id)]['order'] == 32767
     options = grid.get_field_options()
     assert len(options) == 2
     assert options[0].field_id == text_field.id
     assert options[0].width == 300
     assert options[0].hidden is True
+    assert options[0].order == 32767
     assert options[1].field_id == number_field.id
     assert options[1].width == 200
     assert options[1].hidden is False
+    assert options[1].order == 32767
 
     url = reverse('api:database:views:grid:list', kwargs={'view_id': grid.id})
     response = api_client.patch(
diff --git a/backend/tests/baserow/contrib/database/view/test_view_models.py b/backend/tests/baserow/contrib/database/view/test_view_models.py
index dd4999ae0..14939d062 100644
--- a/backend/tests/baserow/contrib/database/view/test_view_models.py
+++ b/backend/tests/baserow/contrib/database/view/test_view_models.py
@@ -14,8 +14,10 @@ def test_grid_view_get_field_options(data_fixture):
     assert len(field_options) == 2
     assert field_options[0].field_id == field_1.id
     assert field_options[0].width == 200
+    assert field_options[0].order == 32767
     assert field_options[1].field_id == field_2.id
     assert field_options[1].width == 200
+    assert field_options[1].order == 32767
 
     field_3 = data_fixture.create_text_field(table=table)
 
diff --git a/changelog.md b/changelog.md
index c0aaeb876..261b14657 100644
--- a/changelog.md
+++ b/changelog.md
@@ -6,6 +6,7 @@
 * Refactored the GridView component and improved interface speed.
 * Prevent websocket reconnect when the connection closes without error.
 * Added gunicorn worker test to the CI pipeline.
+* Made it possible to re-order fields in a grid view.
 * Show the number of filters and sorts active in the header of a grid view.
 * The first user to sign-up after installation now gets given staff status.
 
diff --git a/web-frontend/modules/core/assets/scss/components/views/grid.scss b/web-frontend/modules/core/assets/scss/components/views/grid.scss
index 1eaa8504c..fb2c70a43 100644
--- a/web-frontend/modules/core/assets/scss/components/views/grid.scss
+++ b/web-frontend/modules/core/assets/scss/components/views/grid.scss
@@ -466,3 +466,17 @@
     padding-left: 10px;
   }
 }
+
+.grid-view__field-dragging {
+  @include absolute(0, auto);
+
+  z-index: 4;
+  background-color: rgba(0, 0, 0, 0.08);
+}
+
+.grid-view__field-target {
+  @include absolute(0, auto, 48px, auto);
+
+  z-index: 5;
+  border-left: solid 1px $color-primary-900;
+}
diff --git a/web-frontend/modules/database/components/view/grid/GridView.vue b/web-frontend/modules/database/components/view/grid/GridView.vue
index 8e93bb690..4b005087d 100644
--- a/web-frontend/modules/database/components/view/grid/GridView.vue
+++ b/web-frontend/modules/database/components/view/grid/GridView.vue
@@ -53,6 +53,7 @@
       :table="table"
       :view="view"
       :include-add-field="true"
+      :can-order-fields="true"
       :style="{ left: leftWidth + 'px' }"
       @refresh="$emit('refresh', $event)"
       @row-hover="setRowHover($event.row, $event.value)"
@@ -64,6 +65,7 @@
       @unselected="unselectedCell($event)"
       @select-next="selectNextCell($event)"
       @edit-modal="$refs.rowEditModal.show($event.id)"
+      @scroll="scroll($event.pixelY, $event.pixelX)"
     ></GridViewSection>
     <Context ref="rowContext">
       <ul class="context__menu">
diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue
new file mode 100644
index 000000000..8cb45e6c2
--- /dev/null
+++ b/web-frontend/modules/database/components/view/grid/GridViewFieldDragging.vue
@@ -0,0 +1,259 @@
+<template>
+  <div v-show="dragging">
+    <div
+      class="grid-view__field-dragging"
+      :style="{ width: draggingWidth + 'px', left: draggingLeft + 'px' }"
+    ></div>
+    <div
+      class="grid-view__field-target"
+      :style="{ left: targetLeft + 'px' }"
+    ></div>
+  </div>
+</template>
+
+<script>
+import { notifyIf } from '@baserow/modules/core/utils/error'
+import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'
+
+export default {
+  name: 'GridViewFieldDragging',
+  mixins: [gridViewHelpers],
+  props: {
+    view: {
+      type: Object,
+      required: true,
+    },
+    fields: {
+      type: Array,
+      required: true,
+    },
+    containerWidth: {
+      type: Number,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      // Indicates if the user is dragging a field to another position.
+      dragging: false,
+      // The field object that is being dragged.
+      field: null,
+      // The id of the field where the dragged field must be placed after.
+      targetFieldId: null,
+      // The horizontal starting position of the mouse.
+      mouseStart: 0,
+      // The horizontal scrollbar offset starting position.
+      scrollStart: 0,
+      // The width of the dragging animation, this is equal to the width of the field.
+      draggingWidth: 0,
+      // The position of the dragging animation.
+      draggingLeft: 0,
+      // The position of the target indicator where the field is going to be moved to.
+      targetLeft: 0,
+      // The mouse move event.
+      lastMoveEvent: null,
+      // Indicates if the user is auto scrolling at the moment.
+      autoScrolling: false,
+    }
+  },
+  beforeDestroy() {
+    this.cancel()
+  },
+  methods: {
+    getFieldLeft(id) {
+      let left = 0
+      for (let i = 0; i < this.fields.length; i++) {
+        if (this.fields[i].id === id) {
+          break
+        }
+        left += this.getFieldWidth(this.fields[i].id)
+      }
+      return left
+    },
+    /**
+     * Called when the field dragging must start. It will register the global mouse
+     * move, mouse up events and keyup events so that the user can drag the field to
+     * the correct position.
+     */
+    start(field, event) {
+      this.field = field
+      this.targetFieldId = field.id
+      this.dragging = true
+      this.mouseStart = event.clientX
+      this.scrollStart = this.$parent.$el.scrollLeft
+      this.draggingLeft = 0
+      this.targetLeft = 0
+
+      this.$el.moveEvent = (event) => this.move(event)
+      window.addEventListener('mousemove', this.$el.moveEvent)
+
+      this.$el.upEvent = (event) => this.up(event)
+      window.addEventListener('mouseup', this.$el.upEvent)
+
+      this.$el.keydownEvent = (event) => {
+        if (event.keyCode === 27) {
+          // When the user presses the escape key we want to cancel the action
+          this.cancel(event)
+        }
+      }
+      document.body.addEventListener('keydown', this.$el.keydownEvent)
+      this.move(event, false)
+    },
+    /**
+     * The move method is called when every time the user moves the mouse while
+     * dragging a field. It can also be called while auto scrolling.
+     */
+    move(event = null, startAutoScroll = true) {
+      if (event !== null) {
+        event.preventDefault()
+        this.lastMoveEvent = event
+      } else {
+        event = this.lastMoveEvent
+      }
+
+      // This is the horizontally scrollable element.
+      const element = this.$parent.$el
+
+      this.draggingWidth = this.getFieldWidth(this.field.id)
+
+      // Calculate the left position of the dragging animation. This is the transparent
+      // overlay that has the same width as the field.
+      this.draggingLeft = Math.min(
+        this.getFieldLeft(this.field.id) +
+          event.clientX -
+          this.mouseStart +
+          this.$parent.$el.scrollLeft -
+          this.scrollStart,
+        this.containerWidth - this.draggingWidth
+      )
+
+      // Calculate which after which field we want to place the field that is currently
+      // being dragged. This is named the target. We also calculate what position the
+      // field would have for visualisation purposes.
+      const mouseLeft =
+        event.clientX -
+        element.getBoundingClientRect().left +
+        element.scrollLeft
+      let left = 0
+      for (let i = 0; i < this.fields.length; i++) {
+        const width = this.getFieldWidth(this.fields[i].id)
+        const nextWidth =
+          i + 1 < this.fields.length
+            ? this.getFieldWidth(this.fields[i + 1].id)
+            : width
+        const leftHalf = left + Math.floor(width / 2)
+        const rightHalf = left + width + Math.floor(nextWidth / 2)
+        if (i === 0 && mouseLeft < leftHalf) {
+          this.targetFieldId = 0
+          // The value 1 makes sure it is visible instead of falling outside of the
+          // view port.
+          this.targetLeft = 1
+          break
+        }
+        if (mouseLeft > leftHalf && mouseLeft < rightHalf) {
+          this.targetFieldId = this.fields[i].id
+          this.targetLeft = left + width
+          break
+        }
+        left += width
+      }
+
+      // If the user is not already auto scrolling, which happens while dragging and
+      // moving the element outside of the view port at the left or right side, we
+      // might need to initiate that process.
+      if (!this.autoScrolling || !startAutoScroll) {
+        const relativeLeft = this.draggingLeft - element.scrollLeft
+        const relativeRight = relativeLeft + this.getFieldWidth(this.field.id)
+        const maxScrollLeft = element.scrollWidth - element.clientWidth
+        let speed = 0
+
+        if (relativeLeft < 0 && element.scrollLeft > 0) {
+          // If the dragging animation falls out of the left side of the viewport we
+          // need to auto scroll to the left.
+          speed = -Math.ceil(Math.min(Math.abs(relativeLeft), 100) / 20)
+        } else if (
+          relativeRight > element.clientWidth &&
+          element.scrollLeft < maxScrollLeft
+        ) {
+          // If the dragging animation falls out of the right side of the viewport we
+          // need to auto scroll to the right.
+          speed = Math.ceil(
+            Math.min(relativeRight - element.clientWidth, 100) / 20
+          )
+        }
+
+        // If the speed is either a position or negative, so not 0, we know that we
+        // need to start auto scrolling.
+        if (speed !== 0) {
+          this.autoScrolling = true
+          this.$emit('scroll', { pixelY: 0, pixelX: speed })
+          this.$el.scrollTimeout = setTimeout(() => {
+            this.move(null, false)
+          }, 1)
+        } else {
+          this.autoScrolling = false
+        }
+      }
+    },
+    /**
+     * Can be called when the current dragging state needs to be stopped. It will
+     * remove all the created event listeners and timeouts.
+     */
+    cancel() {
+      this.dragging = false
+      window.removeEventListener('mousemove', this.$el.moveEvent)
+      window.removeEventListener('mouseup', this.$el.upEvent)
+      document.body.addEventListener('keydown', this.$el.keydownEvent)
+      clearTimeout(this.$el.scrollTimeout)
+    },
+    /**
+     * Called when the user releases the mouse on a the desired position. It will
+     * calculate the new position of the field in the list and if it has changed
+     * position, then the order in the field options is updated accordingly.
+     */
+    async up(event) {
+      event.preventDefault()
+      this.cancel()
+
+      // We don't need to do anything if the field needs to be placed after itself
+      // because that wouldn't change the position.
+      if (this.field.id === this.targetFieldId) {
+        return
+      }
+
+      const oldOrder = this.fields.map((field) => field.id)
+      // Create an array of field ids in the correct order excluding the field that
+      // needs to be repositioned because that one will be added later.
+      const newOrder = this.fields
+        .filter((field) => field.id !== this.field.id)
+        .map((field) => field.id)
+      if (this.targetFieldId === 0) {
+        // If the target field id is 0 the field needs to be moved to the beginning.
+        newOrder.unshift(this.field.id)
+      } else {
+        // Calculate after which field the field that needs to be repositioned needs to
+        // be placed.
+        const targetIndex = newOrder.findIndex(
+          (id) => id === this.targetFieldId
+        )
+        newOrder.splice(targetIndex + 1, 0, this.field.id)
+      }
+
+      // Check if the new order differs from the old order. If that is not the case we
+      // don't need to update the field options because nothing will be changed.
+      if (JSON.stringify(oldOrder) === JSON.stringify(newOrder)) {
+        return
+      }
+
+      try {
+        await this.$store.dispatch('view/grid/updateFieldOptionsOrder', {
+          gridId: this.view.id,
+          order: newOrder,
+        })
+      } catch (error) {
+        notifyIf(error, 'view')
+      }
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue
index 2aa9c42f0..7eef216c8 100644
--- a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue
+++ b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue
@@ -9,6 +9,7 @@
         view.sortings.findIndex((sort) => sort.field === field.id) !== -1,
     }"
     :style="{ width: width + 'px' }"
+    @mousedown="startDragging($event, field)"
   >
     <div
       class="grid-view__description"
@@ -22,6 +23,7 @@
         ref="contextLink"
         class="grid-view__description-options"
         @click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
+        @mousedown.stop
       >
         <i class="fas fa-caret-down"></i>
       </a>
@@ -132,6 +134,11 @@ export default {
       required: false,
     },
   },
+  data() {
+    return {
+      dragging: false,
+    }
+  },
   computed: {
     width() {
       return this.getFieldWidth(this.field.id)
@@ -210,6 +217,10 @@ export default {
         notifyIf(error, 'view')
       }
     },
+    startDragging(event, field) {
+      event.preventDefault()
+      this.$emit('dragging', { field, event })
+    },
   },
 }
 </script>
diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldWidthHandle.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldWidthHandle.vue
index cc45ac0e8..d1da12ffd 100644
--- a/web-frontend/modules/database/components/view/grid/GridViewFieldWidthHandle.vue
+++ b/web-frontend/modules/database/components/view/grid/GridViewFieldWidthHandle.vue
@@ -1,5 +1,5 @@
 <template>
-  <div :class="{ dragging: dragging }" @mousedown="start($event)"></div>
+  <div :class="{ dragging: dragging }" @mousedown.stop="start($event)"></div>
 </template>
 
 <script>
diff --git a/web-frontend/modules/database/components/view/grid/GridViewHead.vue b/web-frontend/modules/database/components/view/grid/GridViewHead.vue
index a3f39f065..e7dc36de3 100644
--- a/web-frontend/modules/database/components/view/grid/GridViewHead.vue
+++ b/web-frontend/modules/database/components/view/grid/GridViewHead.vue
@@ -14,6 +14,7 @@
       :filters="view.filters"
       :include-field-width-handles="includeFieldWidthHandles"
       @refresh="$emit('refresh', $event)"
+      @dragging="$emit('dragging', $event)"
     ></GridViewFieldType>
     <div
       v-if="includeAddField"
diff --git a/web-frontend/modules/database/components/view/grid/GridViewSection.vue b/web-frontend/modules/database/components/view/grid/GridViewSection.vue
index 9747d7193..c852fb9fc 100644
--- a/web-frontend/modules/database/components/view/grid/GridViewSection.vue
+++ b/web-frontend/modules/database/components/view/grid/GridViewSection.vue
@@ -9,6 +9,10 @@
         :include-row-details="includeRowDetails"
         :include-add-field="includeAddField"
         @refresh="$emit('refresh', $event)"
+        @dragging="
+          canOrderFields &&
+            $refs.fieldDragging.start($event.field, $event.event)
+        "
       ></GridViewHead>
       <div ref="body" class="grid-view__body">
         <div class="grid-view__body-inner">
@@ -34,6 +38,13 @@
         <slot name="foot"></slot>
       </div>
     </div>
+    <GridViewFieldDragging
+      ref="fieldDragging"
+      :view="view"
+      :fields="visibleFields"
+      :container-width="width"
+      @scroll="$emit('scroll', $event)"
+    ></GridViewFieldDragging>
   </div>
 </template>
 
@@ -42,7 +53,9 @@ import GridViewHead from '@baserow/modules/database/components/view/grid/GridVie
 import GridViewPlaceholder from '@baserow/modules/database/components/view/grid/GridViewPlaceholder'
 import GridViewRows from '@baserow/modules/database/components/view/grid/GridViewRows'
 import GridViewRowAdd from '@baserow/modules/database/components/view/grid/GridViewRowAdd'
+import GridViewFieldDragging from '@baserow/modules/database/components/view/grid/GridViewFieldDragging'
 import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'
+import { GridViewType } from '@baserow/modules/database/viewTypes'
 
 export default {
   name: 'GridViewSection',
@@ -51,6 +64,7 @@ export default {
     GridViewPlaceholder,
     GridViewRows,
     GridViewRowAdd,
+    GridViewFieldDragging,
   },
   mixins: [gridViewHelpers],
   props: {
@@ -81,16 +95,49 @@ export default {
       required: false,
       default: () => false,
     },
+    canOrderFields: {
+      type: Boolean,
+      required: false,
+      default: () => false,
+    },
   },
   computed: {
+    /**
+     * Returns only the visible fields in the correct order.
+     */
     visibleFields() {
-      return this.fields.filter((field) => {
-        const exists = Object.prototype.hasOwnProperty.call(
-          this.fieldOptions,
-          field.id
-        )
-        return !exists || (exists && !this.fieldOptions[field.id].hidden)
-      })
+      return this.fields
+        .filter((field) => {
+          const exists = Object.prototype.hasOwnProperty.call(
+            this.fieldOptions,
+            field.id
+          )
+          return !exists || (exists && !this.fieldOptions[field.id].hidden)
+        })
+        .sort((a, b) => {
+          const orderA = this.fieldOptions[a.id]
+            ? this.fieldOptions[a.id].order
+            : GridViewType.getMaxPossibleOrderValue()
+          const orderB = this.fieldOptions[b.id]
+            ? this.fieldOptions[b.id].order
+            : GridViewType.getMaxPossibleOrderValue()
+
+          // First by order.
+          if (orderA > orderB) {
+            return 1
+          } else if (orderA < orderB) {
+            return -1
+          }
+
+          // Then by id.
+          if (a.id < b.id) {
+            return -1
+          } else if (a.id > b.id) {
+            return 1
+          } else {
+            return 0
+          }
+        })
     },
     /**
      * Calculates the total width of the whole section based on the fields and the
diff --git a/web-frontend/modules/database/store/field.js b/web-frontend/modules/database/store/field.js
index 8f83d7279..f7ccc5daf 100644
--- a/web-frontend/modules/database/store/field.js
+++ b/web-frontend/modules/database/store/field.js
@@ -224,10 +224,20 @@ export const actions = {
   /**
    * Remove the field from the items without calling the server.
    */
-  forceDelete({ commit, dispatch }, field) {
+  async forceDelete(context, field) {
+    const { commit, dispatch } = context
+
     // Also delete the related filters if there are any.
     dispatch('view/fieldDeleted', { field }, { root: true })
     commit('DELETE_ITEM', field.id)
+
+    // Call the field delete event on all the registered views because they might
+    // need to change things in loaded data. For example the grid field will remove the
+    // field options of that field.
+    const fieldType = this.$registry.get('field', field.type)
+    for (const viewType of Object.values(this.$registry.getAll('view'))) {
+      await viewType.fieldDeleted(context, field, fieldType)
+    }
   },
 }
 
diff --git a/web-frontend/modules/database/store/view/grid.js b/web-frontend/modules/database/store/view/grid.js
index 9182b7160..a7c2fb189 100644
--- a/web-frontend/modules/database/store/view/grid.js
+++ b/web-frontend/modules/database/store/view/grid.js
@@ -4,6 +4,7 @@ import _ from 'lodash'
 import BigNumber from 'bignumber.js'
 
 import { uuid } from '@baserow/modules/core/utils/string'
+import { clone } from '@baserow/modules/core/utils/object'
 import GridService from '@baserow/modules/database/services/view/grid'
 import RowService from '@baserow/modules/database/services/row'
 import {
@@ -235,6 +236,11 @@ export const mutations = {
       })
     }
   },
+  DELETE_FIELD_OPTIONS(state, fieldId) {
+    if (Object.prototype.hasOwnProperty.call(state.fieldOptions, fieldId)) {
+      delete state.fieldOptions[fieldId]
+    }
+  },
   SET_ROW_HOVER(state, { row, value }) {
     row._.hover = value
   },
@@ -899,6 +905,47 @@ export const actions = {
   forceUpdateAllFieldOptions({ commit }, fieldOptions) {
     commit('UPDATE_ALL_FIELD_OPTIONS', fieldOptions)
   },
+  /**
+   * Updates the order of all the available field options. The provided order parameter
+   * should be an array containing the field ids in the correct order.
+   */
+  async updateFieldOptionsOrder(
+    { commit, getters, dispatch },
+    { gridId, order }
+  ) {
+    const oldFieldOptions = clone(getters.getAllFieldOptions)
+    const newFieldOptions = clone(getters.getAllFieldOptions)
+
+    // Update the order of the field options that have not been provided in the order.
+    // They will get a position that places them after the provided field ids.
+    let i = 0
+    Object.keys(newFieldOptions).forEach((fieldId) => {
+      if (!order.includes(parseInt(fieldId))) {
+        newFieldOptions[fieldId].order = order.length + i
+        i++
+      }
+    })
+
+    // Update create the field options and set the correct order value.
+    order.forEach((fieldId, index) => {
+      const id = fieldId.toString()
+      if (Object.prototype.hasOwnProperty.call(newFieldOptions, id)) {
+        newFieldOptions[fieldId.toString()].order = index
+      }
+    })
+
+    return await dispatch('updateAllFieldOptions', {
+      gridId,
+      oldFieldOptions,
+      newFieldOptions,
+    })
+  },
+  /**
+   * Deletes the field options of the provided field id if they exist.
+   */
+  forceDeleteFieldOptions({ commit }, fieldId) {
+    commit('DELETE_FIELD_OPTIONS', fieldId)
+  },
   setRowHover({ commit }, { row, value }) {
     commit('SET_ROW_HOVER', { row, value })
   },
diff --git a/web-frontend/modules/database/viewTypes.js b/web-frontend/modules/database/viewTypes.js
index 6a12981b0..0e873f4ae 100644
--- a/web-frontend/modules/database/viewTypes.js
+++ b/web-frontend/modules/database/viewTypes.js
@@ -112,6 +112,12 @@ export class ViewType extends Registerable {
    */
   fieldCreated(context, table, field, fieldType) {}
 
+  /**
+   * Method that is called when a field has been deleted. This can be useful to
+   * maintain data integrity.
+   */
+  fieldDeleted(context, field, fieldType) {}
+
   /**
    * Method that is called when a field has been changed. This can be useful to
    * maintain data integrity by updating the values.
@@ -154,6 +160,10 @@ export class ViewType extends Registerable {
 }
 
 export class GridViewType extends ViewType {
+  static getMaxPossibleOrderValue() {
+    return 32767
+  }
+
   static getType() {
     return 'grid'
   }
@@ -194,12 +204,19 @@ export class GridViewType extends ViewType {
         values: {
           width: 200,
           hidden: false,
+          order: GridViewType.getMaxPossibleOrderValue(),
         },
       },
       { root: true }
     )
   }
 
+  async fieldDeleted({ dispatch }, field, fieldType) {
+    await dispatch('view/grid/forceDeleteFieldOptions', field.id, {
+      root: true,
+    })
+  }
+
   isCurrentView(store, tableId) {
     const table = store.getters['table/getSelected']
     const grid = store.getters['view/getSelected']