From d38678c5c7c2c297358ed60865b1fae9438afab0 Mon Sep 17 00:00:00 2001
From: Cezary Statkiewicz <cezary@baserow.io>
Date: Tue, 28 May 2024 18:39:39 +0000
Subject: [PATCH] #2090 field description

---
 .../database/api/fields/serializers.py        |  35 +++-
 .../contrib/database/fields/handler.py        |   4 +-
 .../baserow/contrib/database/fields/models.py |   3 +
 .../migrations/0158_field_description.py      |  19 ++
 .../database/api/fields/test_field_views.py   |  39 ++++-
 .../views/gallery/test_gallery_view_views.py  |   1 +
 .../api/views/grid/test_grid_view_views.py    |   1 +
 .../ws/public/test_public_ws_view_signals.py  |   2 +
 .../feature/2090_field_description.json       |   7 +
 .../components/MembersRoleField.vue           |   2 +-
 .../api/views/views/test_calendar_views.py    |   2 +
 .../api/views/views/test_kanban_views.py      |   4 +
 .../core/assets/scss/components/context.scss  |   8 +
 .../scss/components/rich_text_editor.scss     |  17 ++
 .../core/assets/scss/components/tooltip.scss  |  31 ++++
 .../assets/scss/components/views/grid.scss    |   9 +
 .../modules/core/components/HelpIcon.vue      |  88 +++++++++-
 .../modules/core/directives/tooltip.js        | 162 ++++++++++++++++--
 web-frontend/modules/core/mixins/form.js      |   5 +-
 web-frontend/modules/core/utils/dom.js        |   8 +
 .../docs/sections/APIDocsTableListFields.vue  |   3 +
 .../components/field/CreateFieldContext.vue   |  33 +++-
 .../database/components/field/FieldForm.vue   |  57 +++++-
 .../components/field/UpdateFieldContext.vue   |  42 ++++-
 .../components/row/RowEditModalField.vue      |  14 ++
 .../view/grid/GridViewFieldType.vue           |  31 +++-
 web-frontend/modules/database/fieldTypes.js   |   3 +-
 web-frontend/modules/database/locales/en.json |   7 +-
 .../__snapshots__/publicView.spec.js.snap     |  32 +++-
 .../gridViewDecoration.spec.js.snap           | 144 +++++++++++-----
 30 files changed, 709 insertions(+), 104 deletions(-)
 create mode 100644 backend/src/baserow/contrib/database/migrations/0158_field_description.py
 create mode 100644 changelog/entries/unreleased/feature/2090_field_description.json

diff --git a/backend/src/baserow/contrib/database/api/fields/serializers.py b/backend/src/baserow/contrib/database/api/fields/serializers.py
index 28fbfdb24..c14ab6feb 100644
--- a/backend/src/baserow/contrib/database/api/fields/serializers.py
+++ b/backend/src/baserow/contrib/database/api/fields/serializers.py
@@ -34,10 +34,25 @@ class FieldSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Field
-        fields = ("id", "table_id", "name", "order", "type", "primary", "read_only")
+        fields = (
+            "id",
+            "table_id",
+            "name",
+            "order",
+            "type",
+            "primary",
+            "read_only",
+            "description",
+        )
         extra_kwargs = {
             "id": {"read_only": True},
             "table_id": {"read_only": True},
+            "description": {
+                "required": False,
+                "default": None,
+                "allow_null": True,
+                "allow_blank": True,
+            },
         }
 
     @extend_schema_field(OpenApiTypes.STR)
@@ -84,7 +99,15 @@ class CreateFieldSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Field
-        fields = ("name", "type")
+        fields = ("name", "type", "description")
+        extra_kwargs = {
+            "description": {
+                "required": False,
+                "default": None,
+                "allow_null": True,
+                "allow_blank": True,
+            }
+        }
 
 
 class UpdateFieldSerializer(serializers.ModelSerializer):
@@ -94,9 +117,15 @@ class UpdateFieldSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Field
-        fields = ("name", "type")
+        fields = ("name", "type", "description")
         extra_kwargs = {
             "name": {"required": False},
+            "description": {
+                "required": False,
+                "default": None,
+                "allow_null": True,
+                "allow_blank": True,
+            },
         }
 
 
diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py
index ac684cd09..f3655563e 100644
--- a/backend/src/baserow/contrib/database/fields/handler.py
+++ b/backend/src/baserow/contrib/database/fields/handler.py
@@ -291,6 +291,7 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
         return_updated_fields=False,
         primary_key=None,
         skip_search_updates=False,
+        description: Optional[str] = None,
         **kwargs,
     ) -> Union[Field, Tuple[Field, List[Field]]]:
         """
@@ -366,6 +367,7 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
             primary=primary,
             pk=primary_key,
             tsvector_column_created=table.tsvectors_are_supported,
+            description=description,
             **field_values,
         )
 
@@ -510,7 +512,7 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
             dependants_broken_due_to_type_change = []
             to_field_type = from_field_type
 
-        allowed_fields = ["name"] + to_field_type.allowed_fields
+        allowed_fields = ["name", "description"] + to_field_type.allowed_fields
         field_values = extract_allowed(kwargs, allowed_fields)
 
         self._validate_name_and_optionally_rename_if_collision(
diff --git a/backend/src/baserow/contrib/database/fields/models.py b/backend/src/baserow/contrib/database/fields/models.py
index 4e9b3c1a8..4e887061e 100644
--- a/backend/src/baserow/contrib/database/fields/models.py
+++ b/backend/src/baserow/contrib/database/fields/models.py
@@ -122,6 +122,9 @@ class Field(
         "search release which haven't been lazily migrated yet. Or for "
         "users who have turned off full text search entirely.",
     )
+    description = models.TextField(
+        help_text="Field description", default=None, null=True
+    )
 
     class Meta:
         ordering = (
diff --git a/backend/src/baserow/contrib/database/migrations/0158_field_description.py b/backend/src/baserow/contrib/database/migrations/0158_field_description.py
new file mode 100644
index 000000000..4a11f42bc
--- /dev/null
+++ b/backend/src/baserow/contrib/database/migrations/0158_field_description.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.1.13 on 2024-05-13 12:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('database', '0157_alter_airtableimportjob_database')
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="field",
+            name="description",
+            field=models.TextField(
+                default=None, help_text="Field description", null=True
+            ),
+        ),
+    ]
diff --git a/backend/tests/baserow/contrib/database/api/fields/test_field_views.py b/backend/tests/baserow/contrib/database/api/fields/test_field_views.py
index e0c66d89e..ff5b38441 100644
--- a/backend/tests/baserow/contrib/database/api/fields/test_field_views.py
+++ b/backend/tests/baserow/contrib/database/api/fields/test_field_views.py
@@ -209,7 +209,31 @@ def test_create_field(api_client, data_fixture):
 
     response = api_client.post(
         reverse("api:database:fields:list", kwargs={"table_id": table.id}),
-        {"name": "Test 1", "type": "text", "text_default": "default!"},
+        {"name": "Test no description", "type": "text", "text_default": "default!"},
+        format="json",
+        HTTP_AUTHORIZATION=f"JWT {jwt_token}",
+    )
+    response_json = response.json()
+    assert response.status_code == HTTP_200_OK
+    assert response_json["type"] == "text"
+
+    text = TextField.objects.filter()[0]
+    assert response_json["id"] == text.id
+    assert response_json["name"] == "Test no description"
+    assert response_json["order"] == text.order
+    assert response_json["text_default"] == "default!"
+    assert response_json["description"] is None
+    text.delete()
+
+    description_txt_a = "This is a description"
+    response = api_client.post(
+        reverse("api:database:fields:list", kwargs={"table_id": table.id}),
+        {
+            "name": "Test 1",
+            "type": "text",
+            "text_default": "default!",
+            "description": description_txt_a,
+        },
         format="json",
         HTTP_AUTHORIZATION=f"JWT {jwt_token}",
     )
@@ -222,6 +246,7 @@ def test_create_field(api_client, data_fixture):
     assert response_json["name"] == text.name
     assert response_json["order"] == text.order
     assert response_json["text_default"] == "default!"
+    assert response_json["description"] == description_txt_a
 
     # Test authentication with token
     response = api_client.post(
@@ -310,6 +335,7 @@ def test_get_field(api_client, data_fixture):
     assert response_json["name"] == text.name
     assert response_json["table_id"] == text.table_id
     assert not response_json["text_default"]
+    assert response_json["description"] is None
 
     response = api_client.delete(
         reverse(
@@ -380,7 +406,7 @@ def test_update_field(api_client, data_fixture):
     url = reverse("api:database:fields:item", kwargs={"field_id": text.id})
     response = api_client.patch(
         url,
-        {"name": "Test 1", "text_default": "Something"},
+        {"name": "Test 1", "text_default": "Something", "description": "a description"},
         format="json",
         HTTP_AUTHORIZATION=f"JWT {token}",
     )
@@ -389,6 +415,7 @@ def test_update_field(api_client, data_fixture):
     assert response_json["id"] == text.id
     assert response_json["name"] == "Test 1"
     assert response_json["text_default"] == "Something"
+    assert response_json["description"] == "a description"
 
     text.refresh_from_db()
     assert text.name == "Test 1"
@@ -397,7 +424,12 @@ def test_update_field(api_client, data_fixture):
     url = reverse("api:database:fields:item", kwargs={"field_id": text.id})
     response = api_client.patch(
         url,
-        {"name": "Test 1", "type": "text", "text_default": "Something"},
+        {
+            "name": "Test 1",
+            "type": "text",
+            "text_default": "Something",
+            "description": None,
+        },
         format="json",
         HTTP_AUTHORIZATION=f"JWT {token}",
     )
@@ -405,6 +437,7 @@ def test_update_field(api_client, data_fixture):
     assert response.status_code == HTTP_200_OK
     assert response_json["name"] == "Test 1"
     assert response_json["type"] == "text"
+    assert response_json["description"] is None
 
     url = reverse("api:database:fields:item", kwargs={"field_id": text.id})
     response = api_client.patch(
diff --git a/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py b/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py
index f7a1c4d85..b3995a5b7 100644
--- a/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py
+++ b/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py
@@ -816,6 +816,7 @@ def test_get_public_gallery_view(api_client, data_fixture):
                 "text_default": "",
                 "type": "text",
                 "read_only": False,
+                "description": None,
             }
         ],
         "view": {
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 031beb627..429508d36 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
@@ -3240,6 +3240,7 @@ def test_get_public_grid_view(api_client, data_fixture):
                 "text_default": "",
                 "type": "text",
                 "read_only": False,
+                "description": None,
             }
         ],
         "view": {
diff --git a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py
index 1051ac147..7ee56de54 100644
--- a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py
+++ b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py
@@ -222,6 +222,7 @@ def test_when_field_unhidden_in_public_view_force_refresh_sent(
                             "primary": False,
                             "text_default": "",
                             "read_only": False,
+                            "description": None,
                         }
                     ],
                     "view": view_serialized["view"],
@@ -298,6 +299,7 @@ def test_when_only_field_options_updated_in_public_grid_view_force_refresh_sent(
                             "primary": False,
                             "text_default": "",
                             "read_only": False,
+                            "description": None,
                         }
                     ],
                     "view": view_serialized["view"],
diff --git a/changelog/entries/unreleased/feature/2090_field_description.json b/changelog/entries/unreleased/feature/2090_field_description.json
new file mode 100644
index 000000000..1dd25841d
--- /dev/null
+++ b/changelog/entries/unreleased/feature/2090_field_description.json
@@ -0,0 +1,7 @@
+{
+    "type": "feature",
+    "message": "Support for rich text field description",
+    "issue_number": 2090,
+    "bullet_points": [],
+    "created_at": "2024-04-29"
+}
diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue
index cba4f2e05..b98abadf6 100644
--- a/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue
+++ b/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue
@@ -35,7 +35,7 @@
     <HelpIcon
       v-if="roleUidSelected === 'ADMIN'"
       :tooltip="$t('membersRoleField.adminHelpText')"
-      is-warning
+      icon="warning-triangle"
     />
   </div>
 </template>
diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py
index 413a7d444..7d875292b 100644
--- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py
+++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py
@@ -966,6 +966,7 @@ def test_get_public_calendar_view_with_single_select_and_cover(
                 "date_include_time": False,
                 "date_show_tzinfo": False,
                 "date_time_format": "24",
+                "description": None,
             },
             {
                 "id": public_field.id,
@@ -976,6 +977,7 @@ def test_get_public_calendar_view_with_single_select_and_cover(
                 "text_default": "",
                 "type": "text",
                 "read_only": False,
+                "description": None,
             },
         ],
         "view": {
diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py
index 4ecc1eee7..042731f47 100644
--- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py
+++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py
@@ -1566,6 +1566,7 @@ def test_get_public_kanban_without_with_single_select_and_cover(
                 "text_default": "",
                 "type": "text",
                 "read_only": False,
+                "description": None,
             },
         ],
         "view": {
@@ -1646,6 +1647,7 @@ def test_get_public_kanban_view_with_single_select_and_cover(
                 "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
                 "type": "single_select",
                 "read_only": False,
+                "description": None,
             },
             {
                 "id": cover_field.id,
@@ -1655,6 +1657,7 @@ def test_get_public_kanban_view_with_single_select_and_cover(
                 "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
                 "type": "file",
                 "read_only": False,
+                "description": None,
             },
             {
                 "id": public_field.id,
@@ -1665,6 +1668,7 @@ def test_get_public_kanban_view_with_single_select_and_cover(
                 "text_default": "",
                 "type": "text",
                 "read_only": False,
+                "description": None,
             },
         ],
         "view": {
diff --git a/web-frontend/modules/core/assets/scss/components/context.scss b/web-frontend/modules/core/assets/scss/components/context.scss
index d49a01320..6cefc0e08 100644
--- a/web-frontend/modules/core/assets/scss/components/context.scss
+++ b/web-frontend/modules/core/assets/scss/components/context.scss
@@ -175,6 +175,14 @@
   &--multiple-actions {
     justify-content: space-between;
   }
+
+  &--align-left {
+    justify-content: left;
+  }
+
+  &--align-right {
+    justify-content: right;
+  }
 }
 
 .context__menu-active-icon {
diff --git a/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss b/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss
index b410519c1..93bdf71af 100644
--- a/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss
+++ b/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss
@@ -329,3 +329,20 @@ span[data-type='mention'] {
     background-color: $palette-neutral-100;
   }
 }
+
+.rich-text-editor--fixed-size {
+  display: block;
+  width: 100%;
+  max-width: 400px;
+  max-height: 400px;
+  border: 1px solid $palette-neutral-400;
+  padding: 6px 12px;
+  outline: none;
+  line-height: 100%;
+  height: auto;
+  overflow: auto;
+  scrollbar-gutter: stable;
+  border-radius: 4px;
+
+  @include elevation($elevation-low);
+}
diff --git a/web-frontend/modules/core/assets/scss/components/tooltip.scss b/web-frontend/modules/core/assets/scss/components/tooltip.scss
index a95bbc872..2b1c46ed2 100644
--- a/web-frontend/modules/core/assets/scss/components/tooltip.scss
+++ b/web-frontend/modules/core/assets/scss/components/tooltip.scss
@@ -45,4 +45,35 @@
 
   @include fixed-height(26px, 12px);
   @include rounded($rounded);
+
+  &--expandable {
+    padding: 12px;
+    text-align: left;
+    white-space: nowrap;
+    max-height: 320px;
+    line-height: 20px;
+    font-size: 12px;
+    overflow: auto;
+    height: auto;
+    max-width: 320px;
+    text-wrap: wrap;
+
+    @include rounded($rounded);
+
+    &::-webkit-scrollbar {
+      width: 10px;
+    }
+
+    &::-webkit-scrollbar-track {
+      border-radius: 10px;
+      background-color: transparent;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      border-radius: 10px;
+      background: $color-neutral-500;
+      background-clip: content-box;
+      border: 2px solid transparent;
+    }
+  }
 }
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 4e3549a60..322b7012f 100644
--- a/web-frontend/modules/core/assets/scss/components/views/grid.scss
+++ b/web-frontend/modules/core/assets/scss/components/views/grid.scss
@@ -615,11 +615,20 @@
 
   .grid-view__description-options {
     margin-left: auto;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    vertical-align: center;
+  }
+
+  .grid-view__description-icon-trigger {
+    color: $color-neutral-600;
     cursor: pointer;
     padding: 0 10px;
     height: 100%;
     display: flex;
     align-items: center;
+    vertical-align: center;
 
     &:hover {
       color: $color-neutral-900;
diff --git a/web-frontend/modules/core/components/HelpIcon.vue b/web-frontend/modules/core/components/HelpIcon.vue
index 79a84425c..8c229a002 100644
--- a/web-frontend/modules/core/components/HelpIcon.vue
+++ b/web-frontend/modules/core/components/HelpIcon.vue
@@ -1,8 +1,18 @@
 <template>
-  <i v-tooltip="tooltip" class="help-icon" :class="iconClass"> </i>
+  <i
+    v-tooltip:[tooltipOptions]="tooltipText"
+    class="help-icon"
+    :class="iconClass"
+  >
+  </i>
 </template>
 
 <script>
+import Markdown from 'markdown-it'
+
+const TooltipContentPlain = 'plain'
+const TooltipContentMarkdown = 'markdown'
+
 export default {
   name: 'HelpIcon',
   props: {
@@ -11,17 +21,81 @@ export default {
       required: false,
       default: null,
     },
-    isWarning: {
-      type: Boolean,
+    /**
+     * How many seconds the tooltip should be displayed after mouse moves out of icon/contents?
+     * */
+    tooltipDuration: {
+      type: Number,
       required: false,
-      default: false,
+      default: 0,
+    },
+    /**
+     *  Hints on tooltip content type.
+     *  Possible values are: `plain` | `markdown`
+     * */
+    tooltipContentType: {
+      type: String,
+      required: false,
+      default: TooltipContentPlain,
+      validators: (contentType) => {
+        return [TooltipContentMarkdown, TooltipContentPlain].includes(
+          contentType
+        )
+      },
+    },
+    /**
+     * Iconoir icon name without iconoir- prefix.
+     * */
+    icon: {
+      type: String,
+      required: false,
+      default: 'chat-bubble-question',
+    },
+    /**
+     * Additional css classes for tooltip icon
+     * */
+    tooltipClasses: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    /**
+     * Additional css classes for tooltip content container
+     * */
+    tooltipContentClasses: {
+      type: String,
+      requred: false,
+      default: '',
     },
   },
   computed: {
+    tooltipOptions() {
+      return {
+        duration: this.tooltipDuration,
+        contentIsHtml: this.tooltipContentIsHtml(),
+        contentClasses: this.tooltipContentClasses,
+      }
+    },
+    tooltipText() {
+      if (this.tooltipContentType === TooltipContentMarkdown) {
+        const md = new Markdown()
+        return md.render(this.tooltip)
+      }
+      return this.tooltip
+    },
     iconClass() {
-      return this.isWarning
-        ? 'iconoir-warning-triangle'
-        : 'iconoir-chat-bubble-question'
+      const clsNames = this.tooltipClasses ? [this.tooltipClasses] : []
+      if (this.icon) {
+        clsNames.push(`iconoir-${this.icon}`)
+      }
+      return clsNames.join(' ')
+    },
+  },
+  methods: {
+    tooltipContentIsHtml() {
+      // tooltip directive doesn't need to know anything about markdown conversion,
+      // just whether the content is html or not
+      return this.tooltipContentType !== 'plain'
     },
   },
 }
diff --git a/web-frontend/modules/core/directives/tooltip.js b/web-frontend/modules/core/directives/tooltip.js
index 031ef02bf..9df93beaa 100644
--- a/web-frontend/modules/core/directives/tooltip.js
+++ b/web-frontend/modules/core/directives/tooltip.js
@@ -1,13 +1,70 @@
+import _ from 'lodash'
+import { onClickOutside } from '@baserow/modules/core/utils/dom'
+
+/**
+ * helper function to extract options from element
+ *
+ * If binding provides .arg, the .arg property should be an object:
+ *
+ * {duration: Number, contentIsHtml: Bool, contentClasses: String}
+ *
+ * @param el
+ * @param binding directive binding value
+ * @returns {{duration: number, contentClasses: (*|null), value, contentType: (*|string)}}
+ */
+const getOptions = (el, binding) => {
+  // defaults
+  const tooltipOptions = {
+    duration: 0,
+    contentIsHtml: false,
+    value: null,
+    contentClasses: '',
+  }
+
+  if (binding.arg && _.isObject(binding.arg)) {
+    _.merge(tooltipOptions, binding.arg)
+  }
+  tooltipOptions.value = binding.value
+  return tooltipOptions
+}
+
+// make sure only one tooltip is visible
+let currentTooltip = null
+const switchToTooltip = (el) => {
+  if (currentTooltip != null) {
+    currentTooltip.tooltipClose()
+  }
+  currentTooltip = el
+}
+
 /**
  * This is a very simple and fast tooltip directive. It will add the binding value as
  * tooltip content. The tooltip only shows if there is a value.
+ *
+ * tooltip directive can be customized with arg, where arg is an object:
+ * {
+ *     // duration number of seconds the tooltip should be visible after pointer
+ *     // leaves i icon or tooltip content
+ *  duration: Number,
+ *    // contentIsHtml informs the value is html and should not be escaped (sets .innerHtml directly)
+ *  contentIsHtml: Bool,
+ *    // contentClasses is a string with additional css classes for tooltip content container
+ *  contentClasses: String
+ *  }
+ *
+ * <i v-tooltip:[configObject]="someValue"/>
  */
 export default {
   /**
    * If there is a value and the tooltip has not yet been initialized we can add the
    * mouse events to show and hide the tooltip.
+   *
    */
-  initialize(el, value) {
+
+  initialize(el, binding) {
+    el.tooltipOptions = getOptions(el, binding)
+    el.onClickOutsideCallback = null
+
     el.updatePositionEvent = () => {
       const rect = el.getBoundingClientRect()
       const position = el.getAttribute('tooltip-position') || 'bottom'
@@ -22,7 +79,15 @@ export default {
       const width = rect.right - rect.left
       el.tooltipElement.style.left = rect.left + width / 2 + 'px'
     }
+    el.removeTimeout = () => {
+      if (el.tooltipTimeout) {
+        clearTimeout(el.tooltipTimeout)
+      }
+      el.tooltipTimeout = null
+    }
+
     el.tooltipMouseEnterEvent = () => {
+      switchToTooltip(el)
       const position = el.getAttribute('tooltip-position') || 'bottom'
       const hide = el.getAttribute('hide-tooltip')
 
@@ -30,34 +95,92 @@ export default {
         return
       }
 
-      if (el.tooltipElement) {
-        this.terminate(el)
+      if (!el.tooltipElement) {
+        el.tooltipElement = document.createElement('div')
+
+        const classes = ['tooltip', 'tooltip--body', 'tooltip--center']
+        if (position === 'top') {
+          classes.push('tooltip--top')
+        }
+
+        el.tooltipElement.className = classes.join(' ')
+        document.body.insertBefore(el.tooltipElement, document.body.firstChild)
+
+        el.tooltipContentElement = document.createElement('div')
+
+        el.tooltipElement.appendChild(el.tooltipContentElement)
       }
 
-      el.tooltipElement = document.createElement('div')
-
-      const classes = ['tooltip', 'tooltip--body', 'tooltip--center']
-      if (position === 'top') {
-        classes.push('tooltip--top')
+      if (el.tooltipOptions.contentIsHtml) {
+        el.tooltipContentElement.innerHTML = el.tooltipOptions.value
+      } else {
+        el.tooltipContentElement.textContent = el.tooltipOptions.value
       }
-
-      el.tooltipElement.className = classes.join(' ')
-      document.body.insertBefore(el.tooltipElement, document.body.firstChild)
-
-      el.tooltipContentElement = document.createElement('div')
-      el.tooltipContentElement.className = 'tooltip__content'
-      el.tooltipContentElement.textContent = value
-      el.tooltipElement.appendChild(el.tooltipContentElement)
+      // additional css classes for content container
+      const contentClass = ['tooltip__content']
+      if (el.tooltipOptions.contentClasses) {
+        contentClass.push(el.tooltipOptions.contentClasses)
+      }
+      el.tooltipContentElement.className = contentClass.join(' ')
 
       el.updatePositionEvent()
+      // we just entered, so we don't want any previously set timeout to close
+      // the tooltip content
+      el.removeTimeout()
+
+      // make tooltip content preserved if pointer hovers
+      el.tooltipContentElement.addEventListener('mouseenter', el.removeTimeout)
+      el.tooltipContentElement.addEventListener(
+        'mouseleave',
+        el.tooltipMoveLeaveEvent
+      )
 
       // When the user scrolls or resizes the window it could be possible that the
       // element where the tooltip is anchored to has moved, so then the position
       // needs to be updated. We only want to do this when the tooltip is visible.
       window.addEventListener('scroll', el.updatePositionEvent, true)
       window.addEventListener('resize', el.updatePositionEvent)
+
+      // refresh the callback - old callback should be removed because old instance
+      // is may be longer present, and window object may still have click event handlers
+      // that check if the pointer is in or out of tooltip content element (which itself
+      // is long time gone)
+      el.removeTooltipOutsideClickCallback()
+      el.onClickOutsideCallback = onClickOutside(
+        el.tooltipContentElement,
+        el.tooltipClose
+      )
     }
+    /**
+     * queue a close tooltip action.
+     *
+     * This can be called multiple times. Each call will postpone tooltipClose() call.
+     * This way user can hover in and hover out several times and the tooltip still be
+     * visible, if duration is > 0.
+     */
     el.tooltipMoveLeaveEvent = () => {
+      // we should remove any pending timeout before setting new one, because timeout
+      // should be counted from the last mouse leave event.
+      el.removeTimeout()
+      el.tooltipTimeout = setTimeout(
+        el.tooltipClose,
+        // timeout from caller is in seconds. remember to convert to mseconds
+        el.tooltipOptions.duration * 1000
+      )
+    }
+    el.removeTooltipOutsideClickCallback = () => {
+      if (el.onClickOutsideCallback) {
+        el.onClickOutsideCallback()
+        el.onClickOutsideCallback = null
+      }
+    }
+    /**
+     * actually closing the tooltip here
+     */
+    el.tooltipClose = () => {
+      // cleanup actions: remove window handlers set with onClickOutside()
+      el.removeTooltipOutsideClickCallback()
+
       if (el.tooltipElement) {
         el.tooltipElement.parentNode.removeChild(el.tooltipElement)
         el.tooltipElement = null
@@ -66,10 +189,13 @@ export default {
 
       window.removeEventListener('scroll', el.updatePositionEvent, true)
       window.removeEventListener('resize', el.updatePositionEvent)
+      el.removeTimeout()
     }
+    // those event listeners should be bind all the time to the el element
     el.addEventListener('mouseenter', el.tooltipMouseEnterEvent)
     el.addEventListener('mouseleave', el.tooltipMoveLeaveEvent)
   },
+
   /**
    * If there isn't a value or if the directive is unbinded the tooltipElement can
    * be destroyed if it wasn't already and all the events can be removed.
@@ -94,9 +220,9 @@ export default {
     const { value } = binding
 
     if (!!value && el.tooltipElement) {
-      el.tooltipContentElement.textContent = value
+      el.tooltipOptions = getOptions(el, binding)
     } else if (!!value && el.tooltipElement === null) {
-      binding.def.initialize(el, value)
+      binding.def.initialize(el, binding)
     } else if (!value) {
       binding.def.terminate(el)
     }
diff --git a/web-frontend/modules/core/mixins/form.js b/web-frontend/modules/core/mixins/form.js
index 87e00849e..5350c7816 100644
--- a/web-frontend/modules/core/mixins/form.js
+++ b/web-frontend/modules/core/mixins/form.js
@@ -109,7 +109,10 @@ export default {
      * Returns true if the field value has no errors
      */
     fieldHasErrors(fieldName) {
-      return this.$v.values[fieldName].$error
+      // a field can be without any validators
+      return this.$v.values[fieldName]
+        ? this.$v.values[fieldName].$error
+        : false
     },
     /**
      * Returns true is everything is valid.
diff --git a/web-frontend/modules/core/utils/dom.js b/web-frontend/modules/core/utils/dom.js
index d0db99cd6..46a9a0513 100644
--- a/web-frontend/modules/core/utils/dom.js
+++ b/web-frontend/modules/core/utils/dom.js
@@ -56,6 +56,14 @@ export const findScrollableParent = (element) => {
   }
 }
 
+/**
+ * Detects clicks outside el element and call callback
+ *
+ * Returns a callback to unregister click handlers after successful outside click
+ * @param el
+ * @param callback
+ * @returns {(function(): void)|*}
+ */
 export const onClickOutside = (el, callback) => {
   const insideEvent = new Set()
 
diff --git a/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue b/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue
index c24853e85..0a1b2a089 100644
--- a/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue
+++ b/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue
@@ -33,6 +33,9 @@
         <APIDocsParameter name="read_only" :optional="false" type="boolean">
           {{ $t('apiDocsTableListFields.readOnly') }}
         </APIDocsParameter>
+        <APIDocsParameter name="description" :optional="false" type="string">
+          {{ $t('apiDocsTableListFields.descriptionField') }}
+        </APIDocsParameter>
       </ul>
       <p class="api-docs__content">
         {{ $t('apiDocsTableListFields.extraProps') }}
diff --git a/web-frontend/modules/database/components/field/CreateFieldContext.vue b/web-frontend/modules/database/components/field/CreateFieldContext.vue
index fbe25a187..d0306cfae 100644
--- a/web-frontend/modules/database/components/field/CreateFieldContext.vue
+++ b/web-frontend/modules/database/components/field/CreateFieldContext.vue
@@ -4,7 +4,10 @@
     class="field-form-context"
     :overflow-scroll="true"
     :max-height-if-outside-viewport="true"
-    @shown="$emit('shown', $event)"
+    @shown="
+      onShow()
+      $emit('shown', $event)
+    "
   >
     <FieldForm
       ref="form"
@@ -16,7 +19,20 @@
       @submitted="submit"
       @keydown-enter="$refs.submitButton.focus()"
     >
-      <div class="context__form-actions">
+      <div
+        class="context__form-actions context__form-actions--multiple-actions"
+      >
+        <span class="context__form-actions--alight-left">
+          <ButtonText
+            v-if="!showDescription"
+            ref="showDescription"
+            icon="iconoir iconoir-plus"
+            type="secondary"
+            @click="showDescriptionField"
+          >
+            {{ $t('fieldForm.addDescription') }}
+          </ButtonText>
+        </span>
         <Button
           ref="submitButton"
           type="primary"
@@ -66,6 +82,7 @@ export default {
   data() {
     return {
       loading: false,
+      showDescription: false,
     }
   },
   methods: {
@@ -111,6 +128,18 @@ export default {
     showFieldTypesDropdown(target) {
       this.$refs.form.showFieldTypesDropdown(target)
     },
+    showDescriptionField(evt) {
+      this.hideDescriptionLink()
+      this.$refs.form.showDescriptionField()
+      evt.stopPropagation()
+      evt.preventDefault()
+    },
+    hideDescriptionLink() {
+      this.showDescription = true
+    },
+    onShow() {
+      this.showDescription = this.$refs.form.isDescriptionFieldNotEmpty()
+    },
   },
 }
 </script>
diff --git a/web-frontend/modules/database/components/field/FieldForm.vue b/web-frontend/modules/database/components/field/FieldForm.vue
index 778710588..8f6563318 100644
--- a/web-frontend/modules/database/components/field/FieldForm.vue
+++ b/web-frontend/modules/database/components/field/FieldForm.vue
@@ -108,6 +108,29 @@
         @suggested-field-name="handleSuggestedFieldName($event)"
       />
     </template>
+
+    <FormElement
+      v-if="showDescription"
+      :error="fieldHasErrors('description')"
+      class="control"
+    >
+      <label class="control__label control__label--small">{{
+        $t('fieldForm.description')
+      }}</label>
+      <div class="control__elements">
+        <RichTextEditor
+          ref="description"
+          :value="editorValue"
+          class="field-form__editor rich-text-editor rich-text-editor--fixed-size"
+          :editable="true"
+          :enter-stop-edit="false"
+          :thin-scrollbar="true"
+          :enable-rich-text-formatting="false"
+          :placeholder="$t('fieldForm.description')"
+          @blur="onDescriptionBlur"
+        />
+      </div>
+    </FormElement>
     <slot v-if="!selectedFieldIsDeactivated"></slot>
   </form>
 </template>
@@ -117,6 +140,7 @@ import { mapGetters } from 'vuex'
 import { required, maxLength } from 'vuelidate/lib/validators'
 
 import { getNextAvailableNameInSequence } from '@baserow/modules/core/utils/string'
+import RichTextEditor from '@baserow/modules/core/components/editor/RichTextEditor.vue'
 import form from '@baserow/modules/core/mixins/form'
 import {
   RESERVED_BASEROW_FIELD_NAMES,
@@ -126,6 +150,7 @@ import {
 // @TODO focus form on open
 export default {
   name: 'FieldForm',
+  components: { RichTextEditor },
   mixins: [form],
   props: {
     table: {
@@ -158,13 +183,15 @@ export default {
   },
   data() {
     return {
-      allowedValues: ['name', 'type'],
+      allowedValues: ['name', 'type', 'description'],
       values: {
         name: '',
         type: this.forcedType || '',
+        description: null,
       },
       isPrefilledWithSuggestedFieldName: false,
       oldValueType: null,
+      showDescription: false,
     }
   },
   computed: {
@@ -190,6 +217,11 @@ export default {
         return false
       }
     },
+    editorValue() {
+      // temp fix to have proper line breaks
+      // this will not be needed when RTE will be in minimal mode
+      return (this.values.description || '').replaceAll('\n', '<br/>')
+    },
     ...mapGetters({
       fields: 'field/getAll',
     }),
@@ -277,6 +309,29 @@ export default {
         this.$refs[`deactivatedClickModal-${fieldType.type}`][0].show()
       }
     },
+    /**
+     * This sets the showDescription flag to display description text editor, even
+     * if values.description is empty.
+     *
+     * Used by parent components.
+     */
+    showDescriptionField() {
+      this.showDescription = true
+    },
+    /**
+     * Helper method to get information if description is not empty.
+     * Used by parent components
+     */
+    isDescriptionFieldNotEmpty() {
+      this.showDescription = !!this.values.description
+      return this.showDescription
+    },
+    onDescriptionBlur() {
+      // Handle blur event on field description text editor.
+      // A bit hacky way to get current state of description editor once the
+      // edition finished.
+      this.values.description = this.$refs.description.editor.getText()
+    },
   },
 }
 </script>
diff --git a/web-frontend/modules/database/components/field/UpdateFieldContext.vue b/web-frontend/modules/database/components/field/UpdateFieldContext.vue
index a9bf7ee18..5ee0bf8d6 100644
--- a/web-frontend/modules/database/components/field/UpdateFieldContext.vue
+++ b/web-frontend/modules/database/components/field/UpdateFieldContext.vue
@@ -4,6 +4,7 @@
     class="field-form-context"
     :overflow-scroll="true"
     :max-height-if-outside-viewport="true"
+    @shown="onShow"
   >
     <FieldForm
       ref="form"
@@ -14,17 +15,31 @@
       :all-fields-in-table="allFieldsInTable"
       :database="database"
       @submitted="submit"
+      @description-shown="hideDescriptionLink"
     >
       <div
         class="context__form-actions context__form-actions--multiple-actions"
       >
-        <a @click="cancel">
-          {{ $t('action.cancel') }}
-        </a>
+        <span class="context__form-actions--alight-left">
+          <ButtonText
+            v-if="!showDescription"
+            ref="showDescription"
+            icon="iconoir iconoir-plus"
+            type="secondary"
+            @click="showDescriptionField"
+          >
+            {{ $t('fieldForm.addDescription') }}
+          </ButtonText>
+        </span>
 
-        <Button :loading="loading" :disabled="loading || fieldTypeDisabled">
-          {{ $t('action.save') }}
-        </Button>
+        <span class="context__form-actions--align-right">
+          <span class="margin-right-2">
+            <a class="form-action" @click="cancel">{{ $t('action.cancel') }}</a>
+          </span>
+          <Button :loading="loading" :disabled="loading || fieldTypeDisabled">
+            {{ $t('action.save') }}
+          </Button>
+        </span>
       </div>
     </FieldForm>
   </Context>
@@ -64,6 +79,7 @@ export default {
   data() {
     return {
       loading: false,
+      showDescription: false,
     }
   },
   computed: {
@@ -87,6 +103,7 @@ export default {
   },
   methods: {
     reset() {
+      this.showDescription = false
       this.$nextTick(() => {
         this.$refs.form && this.$refs.form.reset()
       })
@@ -131,6 +148,19 @@ export default {
       this.reset()
       this.hide()
     },
+    onShow() {
+      this.showDescription = this.$refs.form.isDescriptionFieldNotEmpty()
+    },
+
+    showDescriptionField(evt) {
+      this.hideDescriptionLink()
+      this.$refs.form.showDescriptionField()
+      evt.stopPropagation()
+      evt.preventDefault()
+    },
+    hideDescriptionLink() {
+      this.showDescription = true
+    },
   },
 }
 </script>
diff --git a/web-frontend/modules/database/components/row/RowEditModalField.vue b/web-frontend/modules/database/components/row/RowEditModalField.vue
index c8f0695f5..a5538d9f7 100644
--- a/web-frontend/modules/database/components/row/RowEditModalField.vue
+++ b/web-frontend/modules/database/components/row/RowEditModalField.vue
@@ -7,6 +7,15 @@
       ></a>
       <i class="control__label-icon" :class="field._.type.iconClass"></i>
       {{ field.name }}
+      <span v-if="field.description" class="margin-left-1">
+        <HelpIcon
+          :tooltip="descriptionText"
+          :tooltip-duration="3"
+          :tooltip-content-type="'html'"
+          :tooltip-content-classes="'tooltip__content--expandable'"
+          :icon="'info-empty'"
+        />
+      </span>
       <i
         v-if="!readOnly && canModifyFields"
         ref="contextLink"
@@ -112,6 +121,11 @@ export default {
       default: () => true,
     },
   },
+  computed: {
+    descriptionText() {
+      return (this.field.description || '').replaceAll('\n', '<br/>')
+    },
+  },
   methods: {
     getFieldComponent(type) {
       return this.$registry
diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue
index d24e54165..67fd1f5f6 100644
--- a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue
+++ b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue
@@ -28,15 +28,25 @@
       <div v-if="field.error" class="grid-view__description-icon-error">
         <i v-tooltip="field.error" class="iconoir-warning-triangle"></i>
       </div>
-      <a
-        v-if="!readOnly && showFieldContext"
-        ref="contextLink"
-        class="grid-view__description-options"
-        @click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
-        @mousedown.stop
-      >
-        <i class="iconoir-nav-arrow-down"></i>
-      </a>
+      <span class="grid-view__description-options">
+        <HelpIcon
+          v-if="field.description"
+          :tooltip="descriptionText"
+          :tooltip-content-type="'html'"
+          :tooltip-content-classes="'tooltip__content--expandable'"
+          :icon="'info-empty'"
+        />
+
+        <a
+          v-if="!readOnly && showFieldContext"
+          ref="contextLink"
+          class="grid-view__description-icon-trigger"
+          @click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
+          @mousedown.stop
+        >
+          <i class="iconoir-nav-arrow-down"></i>
+        </a>
+      </span>
 
       <FieldContext
         v-if="!readOnly"
@@ -303,6 +313,9 @@ export default {
     }
   },
   computed: {
+    descriptionText() {
+      return (this.field.description || '').replaceAll('\n', '<br/>')
+    },
     width() {
       return this.getFieldWidth(this.field.id)
     },
diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js
index b1b51676a..85f27afc2 100644
--- a/web-frontend/modules/database/fieldTypes.js
+++ b/web-frontend/modules/database/fieldTypes.js
@@ -568,7 +568,7 @@ export class FieldType extends Registerable {
    * @returns a sample for this field.
    */
   getDocsFieldResponseExample(
-    { id, table_id: tableId, name, order, type, primary },
+    { id, table_id: tableId, name, order, type, primary, description },
     readOnly
   ) {
     return {
@@ -579,6 +579,7 @@ export class FieldType extends Registerable {
       type,
       primary,
       read_only: readOnly,
+      description: description || 'A sample description',
     }
   }
 
diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json
index 3b98b258e..0d8e59210 100644
--- a/web-frontend/modules/database/locales/en.json
+++ b/web-frontend/modules/database/locales/en.json
@@ -170,7 +170,8 @@
     "primary": "Indicates if the field is a primary field. If `true` the field cannot be deleted and the value should represent the whole row.",
     "type": "Type defined for this field.",
     "extraProps": "Some extra properties are not described here because they are type specific.",
-    "readOnly": "Indicates whether the field is a read only field. If true, it's not possible to update the cell value."
+    "readOnly": "Indicates whether the field is a read only field. If true, it's not possible to update the cell value.",
+    "descriptionField": "Field description"
   },
   "apiDocsTableDeleteRow": {
     "description": "Deletes an existing {name} row.",
@@ -280,9 +281,11 @@
   },
   "fieldForm": {
     "name": "Name",
+    "description": "Description",
     "fieldAlreadyExists": "A field with this name already exists.",
     "nameNotAllowed": "This field name is not allowed.",
-    "nameTooLong": "This field name is too long."
+    "nameTooLong": "This field name is too long.",
+    "addDescription": "Add description"
   },
   "fieldSelectThroughFieldSubForm": {
     "noTable": "You need at least one link to table field to create this field.",
diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap
index c7d8c8be0..ee82bbe64 100644
--- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap
+++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap
@@ -393,7 +393,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
                      
                     <!---->
                      
-                    <!---->
+                    <span
+                      class="grid-view__description-options"
+                    >
+                      <!---->
+                       
+                      <!---->
+                    </span>
                      
                     <!---->
                      
@@ -427,7 +433,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
                      
                     <!---->
                      
-                    <!---->
+                    <span
+                      class="grid-view__description-options"
+                    >
+                      <!---->
+                       
+                      <!---->
+                    </span>
                      
                     <!---->
                      
@@ -461,7 +473,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
                      
                     <!---->
                      
-                    <!---->
+                    <span
+                      class="grid-view__description-options"
+                    >
+                      <!---->
+                       
+                      <!---->
+                    </span>
                      
                     <!---->
                      
@@ -495,7 +513,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
                      
                     <!---->
                      
-                    <!---->
+                    <span
+                      class="grid-view__description-options"
+                    >
+                      <!---->
+                       
+                      <!---->
+                    </span>
                      
                     <!---->
                      
diff --git a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap
index 2bf957a11..d1af6c569 100644
--- a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap
+++ b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap
@@ -208,13 +208,19 @@ exports[`GridView component with decoration Default component with first_cell de
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -247,13 +253,19 @@ exports[`GridView component with decoration Default component with first_cell de
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -286,13 +298,19 @@ exports[`GridView component with decoration Default component with first_cell de
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -642,13 +660,19 @@ exports[`GridView component with decoration Default component with row wrapper d
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -681,13 +705,19 @@ exports[`GridView component with decoration Default component with row wrapper d
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -720,13 +750,19 @@ exports[`GridView component with decoration Default component with row wrapper d
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -1079,13 +1115,19 @@ exports[`GridView component with decoration Default component with unavailable d
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -1118,13 +1160,19 @@ exports[`GridView component with decoration Default component with unavailable d
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div
@@ -1157,13 +1205,19 @@ exports[`GridView component with decoration Default component with unavailable d
              
             <!---->
              
-            <a
+            <span
               class="grid-view__description-options"
             >
-              <i
-                class="iconoir-nav-arrow-down"
-              />
-            </a>
+              <!---->
+               
+              <a
+                class="grid-view__description-icon-trigger"
+              >
+                <i
+                  class="iconoir-nav-arrow-down"
+                />
+              </a>
+            </span>
              
              
             <div