From c85b64b82d888d1e36de9339b40b1b91f9663921 Mon Sep 17 00:00:00 2001
From: Peter Evans <peter@baserow.io>
Date: Wed, 6 Nov 2024 06:02:47 +0000
Subject: [PATCH] Resolve "Implement the collection element filter, sort and
 search menu."

---
 .../src/baserow/api/services/serializers.py   |   8 +-
 .../baserow/contrib/database/views/filters.py |   3 +-
 .../integrations/local_baserow/mixins.py      |   2 +-
 .../local_baserow/service_types.py            |   2 +-
 .../local_baserow/test_service_types.py       |   2 +-
 ...n_elements_to_be_filtered_sorted_and_.json |   7 +
 .../elements/baseComponents/ABDropdown.vue    |  12 +-
 .../components/CollectionElementHeader.vue    |  48 ++++
 .../components/RecordSelectorElement.vue      |  21 +-
 .../elements/components/RepeatElement.vue     | 216 ++++++++++--------
 .../elements/components/TableElement.vue      |   9 +-
 .../general/RecordSelectorElementForm.vue     |  26 +--
 .../general/settings/PropertyOptionForm.vue   |   3 +
 web-frontend/modules/builder/elementTypes.js  |  81 +++++++
 web-frontend/modules/builder/locales/en.json  |   4 +
 .../builder/mixins/collectionElement.js       |  20 ++
 .../builder/mixins/collectionElementForm.js   |   7 +-
 .../builder/services/publishedBuilder.js      |  29 ++-
 .../modules/builder/store/elementContent.js   |  11 +-
 .../assets/scss/components/builder/all.scss   |   1 +
 .../builder/collection_element_header.scss    |   4 +
 .../scss/components/integrations/all.scss     |   1 +
 .../local_baserow_adhoc_header.scss           |  10 +
 .../modules/core/components/Context.vue       |  34 ++-
 web-frontend/modules/core/mixins/dropdown.js  |   6 +-
 .../modules/core/plugins/featureFlags.js      |   1 -
 .../database/components/view/ViewSearch.vue   |  21 +-
 .../components/view/ViewSearchContext.vue     |  10 +
 .../components/view/ViewSortContext.vue       |  53 +++--
 web-frontend/modules/database/store/view.js   |  34 ++-
 .../integrations/LocalBaserowAdhocHeader.vue  |  99 ++++++++
 .../modules/integrations/serviceTypes.js      |  28 ++-
 .../__snapshots__/ChoiceElement.spec.js.snap  |  10 +-
 .../RecordSelectorElement.spec.js.snap        |  15 +-
 .../__snapshots__/dropdown.spec.js.snap       |  35 ++-
 .../exportTableModal.spec.js.snap             |   6 +
 .../__snapshots__/viewFilterForm.spec.js.snap |   9 +
 37 files changed, 690 insertions(+), 198 deletions(-)
 create mode 100644 changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json
 create mode 100644 web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue
 create mode 100644 web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss
 create mode 100644 web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss
 create mode 100644 web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue

diff --git a/backend/src/baserow/api/services/serializers.py b/backend/src/baserow/api/services/serializers.py
index 647cd10b8..83ea2aaa4 100644
--- a/backend/src/baserow/api/services/serializers.py
+++ b/backend/src/baserow/api/services/serializers.py
@@ -65,6 +65,7 @@ class PublicServiceSerializer(serializers.ModelSerializer):
     """
 
     type = serializers.SerializerMethodField(help_text="The type of the service.")
+    schema = serializers.SerializerMethodField(help_text="The schema of the service.")
 
     @extend_schema_field(OpenApiTypes.STR)
     def get_type(self, instance):
@@ -74,12 +75,17 @@ class PublicServiceSerializer(serializers.ModelSerializer):
     def get_context_data(self, instance):
         return instance.get_type().get_context_data(instance.specific)
 
+    @extend_schema_field(OpenApiTypes.OBJECT)
+    def get_schema(self, instance):
+        return instance.get_type().generate_schema(instance.specific)
+
     class Meta:
         model = Service
-        fields = ("id", "type")
+        fields = ("id", "type", "schema")
         extra_kwargs = {
             "id": {"read_only": True},
             "type": {"read_only": True},
+            "schema": {"read_only": True},
             "context_data": {"read_only": True},
         }
 
diff --git a/backend/src/baserow/contrib/database/views/filters.py b/backend/src/baserow/contrib/database/views/filters.py
index 58e76d4e5..b613dd8a5 100644
--- a/backend/src/baserow/contrib/database/views/filters.py
+++ b/backend/src/baserow/contrib/database/views/filters.py
@@ -138,7 +138,8 @@ class AdHocFilters:
                 data[key] = sanitize_adhoc_filter_value(value)
 
         api_filters = None
-        if (filters := data.get("filters", None)) and len(filters) > 0:
+        filter_object = {"filters": data}
+        if (filters := filter_object.get("filters", None)) and len(filters) > 0:
             api_filters = validate_api_grouped_filters(
                 data, user_field_names=user_field_names, deserialize_filters=False
             )
diff --git a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py
index 8a2629eb3..6d75504ce 100644
--- a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py
+++ b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py
@@ -359,7 +359,7 @@ class LocalBaserowTableServiceSortableMixin:
         queryset = super().get_queryset(service, table, dispatch_context, model)
 
         adhoc_sort = dispatch_context.sortings()
-        if adhoc_sort is not None and dispatch_context.is_publicly_sortable:
+        if adhoc_sort and dispatch_context.is_publicly_sortable:
             field_names = [field.strip("-") for field in adhoc_sort.split(",")]
             dispatch_context.validate_filter_search_sort_fields(
                 field_names, ServiceAdhocRefinements.SORT
diff --git a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py
index 1c0fa6f12..6ecc67e9b 100644
--- a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py
+++ b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py
@@ -429,7 +429,7 @@ class LocalBaserowTableServiceType(LocalBaserowServiceType):
             "id": {
                 "type": "number",
                 "title": "Id",
-                "sortable": True,
+                "sortable": False,
                 "filterable": False,
                 "searchable": False,
             }
diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py
index 11d7ade5e..d04ce11a2 100644
--- a/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py
+++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py
@@ -868,7 +868,7 @@ def test_local_baserow_table_service_generate_schema_with_interesting_test_table
             "type": "number",
             "title": "Id",
             "metadata": {},
-            "sortable": True,
+            "sortable": False,
             "filterable": False,
             "searchable": False,
         },
diff --git a/changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json b/changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json
new file mode 100644
index 000000000..f8bcf56a9
--- /dev/null
+++ b/changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json
@@ -0,0 +1,7 @@
+{
+    "type": "feature",
+    "message": "[Builder] Allow collection elements to be filtered, sorted and searched against.",
+    "issue_number": 2516,
+    "bullet_points": [],
+    "created_at": "2024-10-24"
+}
\ No newline at end of file
diff --git a/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue b/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue
index 629b186c6..5666045b5 100644
--- a/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue
+++ b/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue
@@ -59,7 +59,7 @@
           class="select__search-input"
           :placeholder="searchText === null ? $t('action.search') : searchText"
           tabindex="0"
-          @keyup="search(query)"
+          @keyup="emitSearch ? $emit('query-change', query) : search(query)"
         />
       </div>
       <ul
@@ -91,5 +91,15 @@ import dropdown from '@baserow/modules/core/mixins/dropdown'
 export default {
   name: 'ABDropdown',
   mixins: [dropdown],
+  props: {
+    /**
+     * When `emitSearch` is set to `true`, this will emit the search
+     * query instead of performing local, per dropdown-item, search.
+     */
+    emitSearch: {
+      type: Boolean,
+      default: false,
+    },
+  },
 }
 </script>
diff --git a/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue b/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue
new file mode 100644
index 000000000..185565291
--- /dev/null
+++ b/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue
@@ -0,0 +1,48 @@
+<template>
+  <component
+    :is="serviceType.adhocHeaderComponent"
+    v-if="dataSource"
+    class="collection-element__header margin-bottom-1"
+    :sortable-properties="
+      elementType.adhocSortableProperties(element, dataSource)
+    "
+    :filterable-properties="
+      elementType.adhocFilterableProperties(element, dataSource)
+    "
+    :searchable-properties="
+      elementType.adhocSearchableProperties(element, dataSource)
+    "
+    @filters-changed="$emit('filters-changed', $event)"
+    @sortings-changed="$emit('sortings-changed', $event)"
+    @search-changed="$emit('search-changed', $event)"
+  />
+</template>
+
+<script>
+export default {
+  inject: ['builder', 'page'],
+  props: {
+    element: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    sharedPage() {
+      return this.$store.getters['page/getSharedPage'](this.builder)
+    },
+    dataSource() {
+      return this.$store.getters['dataSource/getPagesDataSourceById'](
+        [this.page, this.sharedPage],
+        this.element.data_source_id
+      )
+    },
+    elementType() {
+      return this.$registry.get('element', this.element.type)
+    },
+    serviceType() {
+      return this.$registry.get('service', this.dataSource?.type)
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue b/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue
index 467702e8a..b9c319bab 100644
--- a/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue
+++ b/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue
@@ -8,12 +8,14 @@
     <ABDropdown
       ref="recordSelectorDropdown"
       v-model="inputValue"
+      :show-search="adhocSearchEnabled"
+      :emit-search="adhocSearchEnabled"
       class="choice-element"
-      :show-search="false"
       :placeholder="resolvedPlaceholder"
       :multiple="element.multiple"
       :before-show="beforeShow"
       @hide="onFormElementTouch"
+      @query-change="adhocSearch = $event"
       @scroll="$refs.infiniteScroll.handleScroll($event)"
     >
       <template #value>
@@ -24,6 +26,15 @@
           {{ selectedValueDisplay }}
         </span>
       </template>
+      <template #emptyState>
+        {{
+          adhocSearchEnabled
+            ? $t('recordSelectorElement.emptyAdhocState', {
+                query: adhocSearch,
+              })
+            : $t('recordSelectorElement.emptyState')
+        }}
+      </template>
       <template #defaultValue>
         <template v-if="loading">
           <div class="loading" />
@@ -99,6 +110,14 @@ export default {
     }
   },
   computed: {
+    adhocSearchEnabled() {
+      return (
+        this.elementType.adhocSearchableProperties(
+          this.element,
+          this.dataSource
+        ).length > 0
+      )
+    },
     resolvedLabel() {
       return ensureString(this.resolveFormula(this.element.label))
     },
diff --git a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue
index 03c5f539f..43df6f715 100644
--- a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue
+++ b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue
@@ -1,109 +1,121 @@
 <template>
-  <div
-    :class="{
-      [`repeat-element--orientation-${element.orientation}`]: true,
-    }"
-  >
-    <!-- If we have any contents to repeat... -->
-    <template v-if="elementContent.length > 0">
-      <div
-        class="repeat-element__repeated-elements"
-        :style="repeatedElementsStyles"
-      >
-        <!-- Iterate over each content -->
-        <div v-for="(content, index) in elementContent" :key="content.id">
-          <!-- If the container has an children -->
-          <template v-if="children.length > 0">
-            <!-- Iterate over each child -->
-            <template v-for="child in children">
-              <!-- The first iteration is editable if we're in editing mode -->
-              <ElementPreview
-                v-if="index === 0 && isEditMode"
-                :key="`${child.id}-${index}`"
-                :element="child"
-                :application-context-additions="{
-                  recordIndexPath: [
-                    ...applicationContext.recordIndexPath,
-                    index,
-                  ],
-                }"
-                @move="moveElement(child, $event)"
-              />
-              <!-- Other iterations are not editable -->
-              <!-- Override the mode so that any children are in public mode -->
-              <PageElement
-                v-else
-                v-show="!isCollapsed"
-                :key="`${child.id}_${index}`"
-                :element="child"
-                :force-mode="isEditMode ? 'public' : mode"
-                :application-context-additions="{
-                  recordIndexPath: [
-                    ...applicationContext.recordIndexPath,
-                    index,
-                  ],
-                }"
-                :class="{
-                  'repeat-element__preview': index > 0 && isEditMode,
-                }"
-              />
+  <div class="repeat-element--container">
+    <CollectionElementHeader
+      :element="element"
+      @filters-changed="adhocFilters = $event"
+      @sortings-changed="adhocSortings = $event"
+      @search-changed="adhocSearch = $event"
+    ></CollectionElementHeader>
+    <div
+      :class="{
+        [`repeat-element--orientation-${element.orientation}`]: true,
+      }"
+    >
+      <!-- If we have any contents to repeat... -->
+      <template v-if="elementContent.length > 0">
+        <div
+          class="repeat-element__repeated-elements"
+          :style="repeatedElementsStyles"
+        >
+          <!-- Iterate over each content -->
+          <div v-for="(content, index) in elementContent" :key="content.id">
+            <!-- If the container has an children -->
+            <template v-if="children.length > 0">
+              <!-- Iterate over each child -->
+              <template v-for="child in children">
+                <!-- The first iteration is editable if we're in editing mode -->
+                <ElementPreview
+                  v-if="index === 0 && isEditMode"
+                  :key="`${child.id}-${index}`"
+                  :element="child"
+                  :application-context-additions="{
+                    recordIndexPath: [
+                      ...applicationContext.recordIndexPath,
+                      index,
+                    ],
+                  }"
+                  @move="moveElement(child, $event)"
+                />
+                <!-- Other iterations are not editable -->
+                <!-- Override the mode so that any children are in public mode -->
+                <PageElement
+                  v-else
+                  v-show="!isCollapsed"
+                  :key="`${child.id}_${index}`"
+                  :element="child"
+                  :force-mode="isEditMode ? 'public' : mode"
+                  :application-context-additions="{
+                    recordIndexPath: [
+                      ...applicationContext.recordIndexPath,
+                      index,
+                    ],
+                  }"
+                  :class="{
+                    'repeat-element__preview': index > 0 && isEditMode,
+                  }"
+                />
+              </template>
             </template>
-          </template>
+          </div>
         </div>
-      </div>
-      <!-- We have contents, but the container has no children... -->
-      <template v-if="children.length === 0 && isEditMode">
-        <!-- Give the designer the chance to add child elements -->
-        <AddElementZone
-          :disabled="elementIsInError && !elementHasSourceOfData"
-          :tooltip="addElementErrorTooltipMessage"
-          @add-element="showAddElementModal"
-        ></AddElementZone>
-        <AddElementModal
-          ref="addElementModal"
-          :page="page"
-          :element-types-allowed="elementType.childElementTypes(page, element)"
-        ></AddElementModal>
-      </template>
-    </template>
-    <!-- We have no contents to repeat -->
-    <template v-else>
-      <!-- If we also have no children, allow the designer to add elements -->
-      <template v-if="children.length === 0 && isEditMode">
-        <AddElementZone
-          :disabled="elementIsInError && !elementHasSourceOfData"
-          :tooltip="addElementErrorTooltipMessage"
-          @add-element="showAddElementModal"
-        ></AddElementZone>
-        <AddElementModal
-          ref="addElementModal"
-          :page="page"
-          :element-types-allowed="elementType.childElementTypes(page, element)"
-        ></AddElementModal>
-      </template>
-      <!-- We have no contents, but we do have children in edit mode -->
-      <template v-else-if="isEditMode">
-        <div v-if="contentLoading" class="loading"></div>
-        <template v-else>
-          <ElementPreview
-            v-for="child in children"
-            :key="child.id"
-            :element="child"
-            @move="moveElement(child, $event)"
-          />
+        <!-- We have contents, but the container has no children... -->
+        <template v-if="children.length === 0 && isEditMode">
+          <!-- Give the designer the chance to add child elements -->
+          <AddElementZone
+            :disabled="elementIsInError && !elementHasSourceOfData"
+            :tooltip="addElementErrorTooltipMessage"
+            @add-element="showAddElementModal"
+          ></AddElementZone>
+          <AddElementModal
+            ref="addElementModal"
+            :page="page"
+            :element-types-allowed="
+              elementType.childElementTypes(page, element)
+            "
+          ></AddElementModal>
         </template>
       </template>
-    </template>
-    <div class="repeat-element__footer">
-      <ABButton
-        v-if="hasMorePage && children.length > 0"
-        :style="getStyleOverride('button')"
-        :disabled="contentLoading || !contentFetchEnabled"
-        :loading="contentLoading"
-        @click="loadMore()"
-      >
-        {{ resolvedButtonLoadMoreLabel || $t('repeatElement.showMore') }}
-      </ABButton>
+      <!-- We have no contents to repeat -->
+      <template v-else>
+        <!-- If we also have no children, allow the designer to add elements -->
+        <template v-if="children.length === 0 && isEditMode">
+          <AddElementZone
+            :disabled="elementIsInError && !elementHasSourceOfData"
+            :tooltip="addElementErrorTooltipMessage"
+            @add-element="showAddElementModal"
+          ></AddElementZone>
+          <AddElementModal
+            ref="addElementModal"
+            :page="page"
+            :element-types-allowed="
+              elementType.childElementTypes(page, element)
+            "
+          ></AddElementModal>
+        </template>
+        <!-- We have no contents, but we do have children in edit mode -->
+        <template v-else-if="isEditMode">
+          <div v-if="contentLoading" class="loading"></div>
+          <template v-else>
+            <ElementPreview
+              v-for="child in children"
+              :key="child.id"
+              :element="child"
+              @move="moveElement(child, $event)"
+            />
+          </template>
+        </template>
+      </template>
+      <div class="repeat-element__footer">
+        <ABButton
+          v-if="hasMorePage && children.length > 0"
+          :style="getStyleOverride('button')"
+          :disabled="contentLoading || !contentFetchEnabled"
+          :loading="contentLoading"
+          @click="loadMore()"
+        >
+          {{ resolvedButtonLoadMoreLabel || $t('repeatElement.showMore') }}
+        </ABButton>
+      </div>
     </div>
   </div>
 </template>
@@ -120,10 +132,12 @@ import PageElement from '@baserow/modules/builder/components/page/PageElement'
 import { notifyIf } from '@baserow/modules/core/utils/error'
 import { ensureString } from '@baserow/modules/core/utils/validator'
 import { RepeatElementType } from '@baserow/modules/builder/elementTypes'
+import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader'
 
 export default {
   name: 'RepeatElement',
   components: {
+    CollectionElementHeader,
     PageElement,
     ElementPreview,
     AddElementModal,
diff --git a/web-frontend/modules/builder/components/elements/components/TableElement.vue b/web-frontend/modules/builder/components/elements/components/TableElement.vue
index 84eed947a..b3b5a7bfe 100644
--- a/web-frontend/modules/builder/components/elements/components/TableElement.vue
+++ b/web-frontend/modules/builder/components/elements/components/TableElement.vue
@@ -1,5 +1,11 @@
 <template>
   <div class="table-element">
+    <CollectionElementHeader
+      :element="element"
+      @filters-changed="adhocFilters = $event"
+      @sortings-changed="adhocSortings = $event"
+      @search-changed="adhocSearch = $event"
+    ></CollectionElementHeader>
     <ABTable
       :fields="fields"
       :rows="rows"
@@ -62,10 +68,11 @@ import { uuid } from '@baserow/modules/core/utils/string'
 import BaserowTable from '@baserow/modules/builder/components/elements/components/BaserowTable'
 import collectionElement from '@baserow/modules/builder/mixins/collectionElement'
 import { ensureString } from '@baserow/modules/core/utils/validator'
+import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader'
 
 export default {
   name: 'TableElement',
-  components: { BaserowTable },
+  components: { CollectionElementHeader, BaserowTable },
   mixins: [element, collectionElement],
   props: {
     /**
diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue
index a6a599f40..bec26096e 100644
--- a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue
+++ b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue
@@ -194,6 +194,19 @@ export default {
       )
     },
   },
+  watch: {
+    'values.data_source_id': {
+      handler(value) {
+        this.values.data_source_id = value
+
+        // If the data source was removed we should also delete the name formula
+        if (value === null) {
+          this.values.option_name_suffix = ''
+        }
+      },
+      immediate: true,
+    },
+  },
   validations() {
     return {
       values: {
@@ -208,18 +221,5 @@ export default {
       },
     }
   },
-  watch: {
-    'values.data_source_id': {
-      handler(value) {
-        this.values.data_source_id = value
-
-        // If the data source was removed we should also delete the name formula
-        if (value === null) {
-          this.values.option_name_suffix = ''
-        }
-      },
-      immediate: true,
-    },
-  },
 }
 </script>
diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue
index d10f28d2e..42abaa59e 100644
--- a/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue
+++ b/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue
@@ -96,6 +96,9 @@ export default {
         .get('service', this.dataSource.type)
         .getDataSchema(this.dataSource)
     },
+    elementType() {
+      return this.$registry.get('element', this.element.type)
+    },
     /**
      * Returns an object with schema properties as keys and their corresponding
      * property options as values. It's a convenience computed method to easily
diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js
index cce99177e..75b96d066 100644
--- a/web-frontend/modules/builder/elementTypes.js
+++ b/web-frontend/modules/builder/elementTypes.js
@@ -822,6 +822,87 @@ const CollectionElementTypeMixin = (Base) =>
   class extends Base {
     isCollectionElement = true
 
+    /**
+     * A helper function responsible for returning this collection element's
+     * schema properties.
+     */
+    getSchemaProperties(dataSource) {
+      const serviceType = this.app.$registry.get('service', dataSource.type)
+      const schema = serviceType.getDataSchema(dataSource)
+      if (!schema) {
+        return []
+      }
+      return schema.type === 'array'
+        ? schema.items.properties
+        : schema.properties
+    }
+
+    /**
+     * Given a schema property name, is responsible for finding the matching
+     * property option in the element. If it doesn't exist, then we return
+     * an empty object, and it won't be included in the adhoc header.
+     * @param {object} element - the element we want to extract options from.
+     * @param {string} schemaProperty - the schema property name to check.
+     * @returns {object} - the matching property option, or an empty object.
+     */
+    getPropertyOptionsByProperty(element, schemaProperty) {
+      return (
+        element.property_options.find((option) => {
+          return option.schema_property === schemaProperty
+        }) || {}
+      )
+    }
+
+    /**
+     * Responsible for iterating over the schema's properties, filtering
+     * the results down to the properties which are `filterable`, `sortable`,
+     * and `searchable`, and then returning the property value.
+     * @param {string} option - the `filterable`, `sortable` or `searchable`
+     *  property option. If the value is `true` then the property will be
+     *  included in the adhoc header component.
+     * @param {object} element - the element we want to extract options from.
+     * @param {object} dataSource - the dataSource used by `element`.
+     * @returns {array} - an array of schema properties which are present
+     *  in the element's property options where `option` = `true`.
+     */
+    getPropertyOptionByType(option, element, dataSource) {
+      const schemaProperties = dataSource
+        ? this.getSchemaProperties(dataSource)
+        : []
+      return Object.entries(schemaProperties)
+        .filter(
+          ([schemaProperty, _]) =>
+            this.getPropertyOptionsByProperty(element, schemaProperty)[
+              option
+            ] || false
+        )
+        .map(([_, property]) => property)
+    }
+
+    /**
+     * An array of properties within this element which have been flagged
+     * as filterable by the page designer.
+     */
+    adhocFilterableProperties(element, dataSource) {
+      return this.getPropertyOptionByType('filterable', element, dataSource)
+    }
+
+    /**
+     * An array of properties within this element which have been flagged
+     * as sortable by the page designer.
+     */
+    adhocSortableProperties(element, dataSource) {
+      return this.getPropertyOptionByType('sortable', element, dataSource)
+    }
+
+    /**
+     * An array of properties within this element which have been flagged
+     * as searchable by the page designer.
+     */
+    adhocSearchableProperties(element, dataSource) {
+      return this.getPropertyOptionByType('searchable', element, dataSource)
+    }
+
     /**
      * By default collection element will load their content at loading time
      * but if you don't want that you can return false here.
diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json
index 099570e9b..17f27731e 100644
--- a/web-frontend/modules/builder/locales/en.json
+++ b/web-frontend/modules/builder/locales/en.json
@@ -599,6 +599,10 @@
     "toggleEditorRepetitionsLabel": "Temporarily disable repetitions",
     "propertySelectorMissingArrays": "No multiple valued fields found to repeat with."
   },
+  "recordSelectorElement": {
+    "emptyAdhocState": "No records matching '{query}' found.",
+    "emptyState": "No records found."
+  },
   "recordSelectorElementForm": {
     "selectRecordsFrom": "Select records from",
     "noDataSourceMessage": "Choose a data source with multiple rows to list all results.",
diff --git a/web-frontend/modules/builder/mixins/collectionElement.js b/web-frontend/modules/builder/mixins/collectionElement.js
index 8ab4cff2a..7c4c4b99c 100644
--- a/web-frontend/modules/builder/mixins/collectionElement.js
+++ b/web-frontend/modules/builder/mixins/collectionElement.js
@@ -6,6 +6,9 @@ import _ from 'lodash'
 export default {
   data() {
     return {
+      adhocFilters: undefined,
+      adhocSortings: undefined,
+      adhocSearch: undefined,
       currentOffset: 0,
       errorNotified: false,
       resetTimeout: null,
@@ -61,6 +64,13 @@ export default {
     elementHasSourceOfData() {
       return this.elementType.hasSourceOfData(this.element)
     },
+    adhocRefinements() {
+      return {
+        filters: this.adhocFilters,
+        sortings: this.adhocSortings,
+        search: this.adhocSearch,
+      }
+    },
     elementIsInError() {
       return this.elementType.isInError({
         page: this.page,
@@ -92,6 +102,13 @@ export default {
       },
       deep: true,
     },
+    adhocRefinements: {
+      handler(newValue, prevValue) {
+        if (!_.isEqual(newValue, prevValue)) {
+          this.debouncedReset()
+        }
+      },
+    },
   },
   async fetch() {
     if (!this.elementIsInError && this.elementType.fetchAtLoad) {
@@ -122,6 +139,9 @@ export default {
           dataSource: this.dataSource,
           data: this.dispatchContext,
           range,
+          filters: this.adhocRefinements.filters,
+          sortings: this.adhocRefinements.sortings,
+          search: this.adhocRefinements.search,
           mode: this.applicationContext.mode,
           replace,
         })
diff --git a/web-frontend/modules/builder/mixins/collectionElementForm.js b/web-frontend/modules/builder/mixins/collectionElementForm.js
index a4a738a6d..5c41af409 100644
--- a/web-frontend/modules/builder/mixins/collectionElementForm.js
+++ b/web-frontend/modules/builder/mixins/collectionElementForm.js
@@ -1,7 +1,6 @@
 import { mapGetters } from 'vuex'
 import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext'
 import { CurrentRecordDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
-import { FF_PROPERTY_OPTIONS } from '@baserow/modules/core/plugins/featureFlags'
 
 export default {
   mixins: [applicationContextMixin],
@@ -49,11 +48,7 @@ export default {
      * @returns {boolean} - Whether the property options are available.
      */
     propertyOptionsAvailable() {
-      return (
-        this.selectedDataSource &&
-        this.selectedDataSourceReturnsList &&
-        this.$featureFlagIsEnabled(FF_PROPERTY_OPTIONS)
-      )
+      return this.selectedDataSource && this.selectedDataSourceReturnsList
     },
     /**
      * In collection element forms, the ability to view paging options
diff --git a/web-frontend/modules/builder/services/publishedBuilder.js b/web-frontend/modules/builder/services/publishedBuilder.js
index ffda87f25..ec27ee990 100644
--- a/web-frontend/modules/builder/services/publishedBuilder.js
+++ b/web-frontend/modules/builder/services/publishedBuilder.js
@@ -24,14 +24,35 @@ export default (client) => {
         `builder/domains/published/page/${pageId}/workflow_actions/`
       )
     },
-    dispatch(dataSourceId, dispatchContext, { range }) {
+    dispatch(
+      dataSourceId,
+      dispatchContext,
+      { range, filters = {}, sortings = null, search = '', searchMode = '' }
+    ) {
       // Using POST Http method here is not Restful but it the cleanest way to send
       // data with the call without relying on GET parameter and serialization of
       // complex object.
-      const params = {}
+      const params = new URLSearchParams()
       if (range) {
-        params.offset = range[0]
-        params.count = range[1]
+        params.append('offset', range[0])
+        params.append('count', range[1])
+      }
+
+      Object.keys(filters).forEach((key) => {
+        filters[key].forEach((value) => {
+          params.append(key, value)
+        })
+      })
+
+      if (sortings || sortings === '') {
+        params.append('order_by', sortings)
+      }
+
+      if (search) {
+        params.append('search_query', search)
+        if (searchMode) {
+          params.append('search_mode', searchMode)
+        }
       }
 
       return client.post(
diff --git a/web-frontend/modules/builder/store/elementContent.js b/web-frontend/modules/builder/store/elementContent.js
index 8cbd3ab31..ebdbb783f 100644
--- a/web-frontend/modules/builder/store/elementContent.js
+++ b/web-frontend/modules/builder/store/elementContent.js
@@ -58,6 +58,11 @@ const actions = {
    * @param {object} element - the element object
    * @param {object} dataSource - the data source we want to dispatch
    * @param {object} range - the range of the data we want to fetch
+   * @param {object} filters - the adhoc filters to apply to the data
+   * @param {object} sortings - the adhoc sortings to apply to the data
+   * @param {object} search - the adhoc search to apply to the data
+   * @param {string} searchMode - the search mode to apply to the data.
+   * @param {string} mode - the mode of the application
    * @param {object} dispatchContext - the context to dispatch to the data
    * @param {bool} replace - if we want to replace the current content
    * @param {object} data - the query body
@@ -69,6 +74,10 @@ const actions = {
       element,
       dataSource,
       range,
+      filters = {},
+      sortings = null,
+      search = '',
+      searchMode = '',
       mode,
       data: dispatchContext,
       replace = false,
@@ -203,7 +212,7 @@ const actions = {
         const { data } = await service(this.app.$client).dispatch(
           dataSource.id,
           dispatchContext,
-          { range: rangeToFetch }
+          { range: rangeToFetch, filters, sortings, search, searchMode }
         )
 
         // With a list-type data source, the data object will return
diff --git a/web-frontend/modules/core/assets/scss/components/builder/all.scss b/web-frontend/modules/core/assets/scss/components/builder/all.scss
index 8f8e31535..f1bf658e3 100644
--- a/web-frontend/modules/core/assets/scss/components/builder/all.scss
+++ b/web-frontend/modules/core/assets/scss/components/builder/all.scss
@@ -34,3 +34,4 @@
 @import 'padding_selector';
 @import 'page';
 @import 'data_source_item';
+@import 'collection_element_header';
diff --git a/web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss b/web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss
new file mode 100644
index 000000000..0ccaf2a81
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss
@@ -0,0 +1,4 @@
+.element--read-only .collection-element__header {
+  pointer-events: none;
+  user-select: none;
+}
diff --git a/web-frontend/modules/core/assets/scss/components/integrations/all.scss b/web-frontend/modules/core/assets/scss/components/integrations/all.scss
index 583cec281..80b3679ff 100644
--- a/web-frontend/modules/core/assets/scss/components/integrations/all.scss
+++ b/web-frontend/modules/core/assets/scss/components/integrations/all.scss
@@ -1 +1,2 @@
 @import 'local_baserow/local_baserow_form';
+@import 'local_baserow/local_baserow_adhoc_header';
diff --git a/web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss b/web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss
new file mode 100644
index 000000000..9429fb3b6
--- /dev/null
+++ b/web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss
@@ -0,0 +1,10 @@
+.local-baserow-adhoc-header__container {
+  .header__filter-item {
+    margin-left: 0;
+
+    &.header__filter-item--right {
+      margin-left: auto;
+      margin-right: 0;
+    }
+  }
+}
diff --git a/web-frontend/modules/core/components/Context.vue b/web-frontend/modules/core/components/Context.vue
index 9ba804ea2..fc72b91b6 100644
--- a/web-frontend/modules/core/components/Context.vue
+++ b/web-frontend/modules/core/components/Context.vue
@@ -140,9 +140,10 @@ export default {
         // direction, then it will break out of it. We will therefore close it. This can
         // happen the height or width of the viewport decreases.
         if (
-          (css.bottom && css.bottom < 0) ||
+          (css.bottom && css.bottom < this.getWindowScrollHeight()) ||
           (css.right && css.right < 0) ||
-          (css.top && css.top > window.innerHeight)
+          (css.top &&
+            css.top > window.innerHeight + this.getWindowScrollHeight())
         ) {
           this.hide()
           return
@@ -161,7 +162,9 @@ export default {
           const maxHeight =
             css.top || css.bottom
               ? `calc(100vh - ${
-                  (css.top || css.bottom) + this.maxHeightOffset
+                  (css.top || css.bottom) +
+                  this.maxHeightOffset -
+                  this.getWindowScrollHeight()
                 }px)`
               : 'none'
           this.$el.style['max-height'] = maxHeight
@@ -426,15 +429,21 @@ export default {
       }
 
       if (verticalAdjusted === 'bottom') {
-        positions.top = targetBottom + verticalOffset
+        positions.top =
+          targetBottom + verticalOffset + this.getWindowScrollHeight()
       }
 
       if (verticalAdjusted === 'over-bottom' || verticalAdjusted === 'over') {
-        positions.top = targetTop + verticalOffset
+        positions.top =
+          targetTop + verticalOffset + this.getWindowScrollHeight()
       }
 
       if (verticalAdjusted === 'top') {
-        positions.bottom = window.innerHeight - targetTop + verticalOffset
+        positions.bottom =
+          window.innerHeight -
+          targetTop +
+          verticalOffset +
+          this.getWindowScrollHeight()
       }
 
       if (verticalAdjusted === 'over-top' || verticalAdjusted === 'over') {
@@ -469,9 +478,15 @@ export default {
       // with the full height of the element without scrollbar to calculate the optimal
       // position.
       const scrollHeight = this.$el.scrollHeight
-      const canTop = targetRect.top - scrollHeight - verticalOffset > 0
+      const canTop =
+        targetRect.top -
+          scrollHeight -
+          verticalOffset +
+          this.getWindowScrollHeight() >
+        0
       const canBottom =
-        window.innerHeight -
+        window.innerHeight +
+          this.getWindowScrollHeight() -
           targetRect.bottom -
           scrollHeight -
           this.maxHeightOffset -
@@ -507,6 +522,9 @@ export default {
 
       return { vertical, horizontal }
     },
+    getWindowScrollHeight() {
+      return window?.scrollY || 0
+    },
     isOpen() {
       return this.open
     },
diff --git a/web-frontend/modules/core/mixins/dropdown.js b/web-frontend/modules/core/mixins/dropdown.js
index d03a8b477..a1c7bb243 100644
--- a/web-frontend/modules/core/mixins/dropdown.js
+++ b/web-frontend/modules/core/mixins/dropdown.js
@@ -225,7 +225,9 @@ export default {
               : [...items, ...traverse(child.$children)],
           []
         )
-      return traverse(this.$children)
+      const components = traverse(this.$children)
+      this.hasDropdownItem = components.length > 0
+      return components
     },
     focusout(event) {
       // Hide only if we loose focus in favor of another element which is not a
@@ -271,8 +273,6 @@ export default {
       this.opener = isElementOrigin ? target : null
       this.$emit('show')
 
-      this.hasDropdownItem = this.getDropdownItemComponents().length > 0
-
       this.$nextTick(() => {
         // We have to wait for the input to be visible before we can focus.
         this.showSearch && this.$refs.search.focus()
diff --git a/web-frontend/modules/core/plugins/featureFlags.js b/web-frontend/modules/core/plugins/featureFlags.js
index 83458107b..5d74cc73a 100644
--- a/web-frontend/modules/core/plugins/featureFlags.js
+++ b/web-frontend/modules/core/plugins/featureFlags.js
@@ -1,7 +1,6 @@
 const FF_ENABLE_ALL = '*'
 export const FF_EXPORT_WORKSPACE = 'export_workspace'
 export const FF_DASHBOARDS = 'dashboards'
-export const FF_PROPERTY_OPTIONS = 'property_options'
 
 /**
  * A comma separated list of feature flags used to enable in-progress or not ready
diff --git a/web-frontend/modules/database/components/view/ViewSearch.vue b/web-frontend/modules/database/components/view/ViewSearch.vue
index 97ec13f6b..91135cea2 100644
--- a/web-frontend/modules/database/components/view/ViewSearch.vue
+++ b/web-frontend/modules/database/components/view/ViewSearch.vue
@@ -15,6 +15,7 @@
       ref="context"
       :view="view"
       :fields="fields"
+      :read-only="readOnly"
       :store-prefix="storePrefix"
       :always-hide-rows-not-matching-search="alwaysHideRowsNotMatchingSearch"
       @refresh="$emit('refresh', $event)"
@@ -38,9 +39,15 @@ export default {
       type: Array,
       required: true,
     },
+    readOnly: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     storePrefix: {
       type: String,
-      required: true,
+      required: false,
+      default: '',
     },
     alwaysHideRowsNotMatchingSearch: {
       type: Boolean,
@@ -53,6 +60,18 @@ export default {
       headerSearchTerm: '',
     }
   },
+  watch: {
+    $props: {
+      immediate: true,
+      handler() {
+        if (!this.storePrefix.length && !this.readOnly) {
+          throw new Error(
+            'A storePrefix is required when the search is not read-only.'
+          )
+        }
+      },
+    },
+  },
   mounted() {
     this.$priorityBus.$on(
       'start-search',
diff --git a/web-frontend/modules/database/components/view/ViewSearchContext.vue b/web-frontend/modules/database/components/view/ViewSearchContext.vue
index 07df57c0e..f5c1e7f89 100644
--- a/web-frontend/modules/database/components/view/ViewSearchContext.vue
+++ b/web-frontend/modules/database/components/view/ViewSearchContext.vue
@@ -48,6 +48,10 @@ export default {
       type: Array,
       required: true,
     },
+    readOnly: {
+      type: Boolean,
+      required: true,
+    },
     storePrefix: {
       type: String,
       required: true,
@@ -88,6 +92,11 @@ export default {
       this.lastHide = this.hideRowsNotMatchingSearch
     },
     search() {
+      if (this.readOnly) {
+        this.$emit('refresh', { activeSearchTerm: this.activeSearchTerm })
+        return
+      }
+
       this.loading = true
 
       // When the user toggles from hiding rows to not hiding rows we still
@@ -114,6 +123,7 @@ export default {
       )
       this.$emit('refresh', {
         callback: this.finishedLoading,
+        activeSearchTerm: this.activeSearchTerm,
       })
     }, 400),
     // Debounce even the client side only refreshes as otherwise spamming the keyboard
diff --git a/web-frontend/modules/database/components/view/ViewSortContext.vue b/web-frontend/modules/database/components/view/ViewSortContext.vue
index 1609084fa..7b8cc0cd9 100644
--- a/web-frontend/modules/database/components/view/ViewSortContext.vue
+++ b/web-frontend/modules/database/components/view/ViewSortContext.vue
@@ -36,12 +36,12 @@
           </a>
 
           <div class="sortings__description">
-            <template v-if="index === 0">{{
-              $t('viewSortContext.sortBy')
-            }}</template>
-            <template v-if="index > 0">{{
-              $t('viewSortContext.thenBy')
-            }}</template>
+            <template v-if="index === 0"
+              >{{ $t('viewSortContext.sortBy') }}
+            </template>
+            <template v-if="index > 0"
+              >{{ $t('viewSortContext.thenBy') }}
+            </template>
           </div>
           <div class="sortings__field">
             <Dropdown
@@ -69,9 +69,9 @@
               :class="{ active: sort.order === 'ASC' }"
               @click="updateSort(sort, { order: 'ASC' })"
             >
-              <template v-if="getSortIndicator(field, 0) === 'text'">{{
-                getSortIndicator(field, 1)
-              }}</template>
+              <template v-if="getSortIndicator(field, 0) === 'text'"
+                >{{ getSortIndicator(field, 1) }}
+              </template>
               <i
                 v-if="getSortIndicator(field, 0) === 'icon'"
                 :class="getSortIndicator(field, 1)"
@@ -79,9 +79,9 @@
 
               <i class="iconoir-arrow-right"></i>
 
-              <template v-if="getSortIndicator(field, 0) === 'text'">{{
-                getSortIndicator(field, 2)
-              }}</template>
+              <template v-if="getSortIndicator(field, 0) === 'text'"
+                >{{ getSortIndicator(field, 2) }}
+              </template>
               <i
                 v-if="getSortIndicator(field, 0) === 'icon'"
                 :class="getSortIndicator(field, 2)"
@@ -92,9 +92,9 @@
               :class="{ active: sort.order === 'DESC' }"
               @click="updateSort(sort, { order: 'DESC' })"
             >
-              <template v-if="getSortIndicator(field, 0) === 'text'">{{
-                getSortIndicator(field, 2)
-              }}</template>
+              <template v-if="getSortIndicator(field, 0) === 'text'"
+                >{{ getSortIndicator(field, 2) }}
+              </template>
               <i
                 v-if="getSortIndicator(field, 0) === 'icon'"
                 :class="getSortIndicator(field, 2)"
@@ -102,9 +102,9 @@
 
               <i class="iconoir-arrow-right"></i>
 
-              <template v-if="getSortIndicator(field, 0) === 'text'">{{
-                getSortIndicator(field, 1)
-              }}</template>
+              <template v-if="getSortIndicator(field, 0) === 'text'"
+                >{{ getSortIndicator(field, 1) }}
+              </template>
               <i
                 v-if="getSortIndicator(field, 0) === 'icon'"
                 :class="getSortIndicator(field, 1)"
@@ -124,8 +124,8 @@
             $refs.addContext.toggle($refs.addContextToggle, 'bottom', 'left', 4)
           "
         >
-          {{ $t('viewSortContext.addSort') }}</ButtonText
-        >
+          {{ $t('viewSortContext.addSort') }}
+        </ButtonText>
         <Context
           ref="addContext"
           class="sortings__add-context"
@@ -142,7 +142,7 @@
               <a class="context__menu-item-link" @click="addSort(field)">
                 <i
                   class="context__menu-item-icon"
-                  :class="field._.type.iconClass"
+                  :class="getFieldType(field).iconClass"
                 ></i>
                 {{ field.name }}
               </a>
@@ -188,8 +188,11 @@ export default {
     },
   },
   methods: {
+    getFieldType(field) {
+      return this.$registry.get('field', field.type)
+    },
     getCanSortInView(field) {
-      return this.$registry.get('field', field.type).getCanSortInView(field)
+      return this.getFieldType(field).getCanSortInView(field)
     },
     getField(fieldId) {
       for (const i in this.fields) {
@@ -249,9 +252,9 @@ export default {
       }
     },
     getSortIndicator(field, index) {
-      return this.$registry
-        .get('field', field.type)
-        .getSortIndicator(field, this.$registry)[index]
+      return this.getFieldType(field).getSortIndicator(field, this.$registry)[
+        index
+      ]
     },
   },
 }
diff --git a/web-frontend/modules/database/store/view.js b/web-frontend/modules/database/store/view.js
index 1039a8c86..54f8377da 100644
--- a/web-frontend/modules/database/store/view.js
+++ b/web-frontend/modules/database/store/view.js
@@ -140,11 +140,15 @@ export const mutations = {
     if (!state.items.some((existingItem) => existingItem.id === item.id))
       state.items = [...state.items, item].sort((a, b) => a.order - b.order)
   },
-  UPDATE_ITEM(state, { id, values, repopulate }) {
-    const index = state.items.findIndex((item) => item.id === id)
-    Object.assign(state.items[index], state.items[index], values)
-    if (repopulate === true) {
-      populateView(state.items[index], this.$registry)
+  UPDATE_ITEM(state, { id, view, values, repopulate, readOnly }) {
+    if (!readOnly) {
+      const index = state.items.findIndex((item) => item.id === id)
+      Object.assign(state.items[index], state.items[index], values)
+      if (repopulate === true) {
+        populateView(state.items[index], this.$registry)
+      }
+    } else {
+      Object.assign(view, view, values)
     }
   },
   ORDER_ITEMS(state, { ownershipType, order }) {
@@ -440,7 +444,12 @@ export const actions = {
     }
 
     if (optimisticUpdate) {
-      dispatch('forceUpdate', { view, values: newValues, repopulate: true })
+      dispatch('forceUpdate', {
+        view,
+        values: newValues,
+        repopulate: true,
+        readOnly,
+      })
     }
     try {
       if (!readOnly) {
@@ -484,8 +493,17 @@ export const actions = {
   /**
    * Forcefully update an existing view without making a request to the backend.
    */
-  forceUpdate({ commit }, { view, values, repopulate = false }) {
-    commit('UPDATE_ITEM', { id: view.id, values, repopulate })
+  forceUpdate(
+    { commit },
+    { view, values, repopulate = false, readOnly = false }
+  ) {
+    commit('UPDATE_ITEM', {
+      id: view.id,
+      view,
+      values,
+      repopulate,
+      readOnly,
+    })
   },
   /**
    * Duplicates an existing view.
diff --git a/web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue b/web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue
new file mode 100644
index 000000000..634105dda
--- /dev/null
+++ b/web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue
@@ -0,0 +1,99 @@
+<template>
+  <div class="local-baserow-adhoc-header__container">
+    <ul class="header__filter">
+      <li class="header__filter-item">
+        <ViewFilter
+          v-if="filterableFields.length"
+          read-only
+          :view="view"
+          :fields="filterableFields"
+          :disable-filter="false"
+          @changed="handleFiltersChange"
+        ></ViewFilter>
+      </li>
+      <li class="header__filter-item">
+        <ViewSort
+          v-if="sortableFields.length"
+          read-only
+          :view="view"
+          :fields="sortableFields"
+          @changed="handleSortingsChange"
+        ></ViewSort>
+      </li>
+      <li class="header__filter-item header__filter-item--right">
+        <ViewSearch
+          v-if="searchableFields.length"
+          read-only
+          always-hide-rows-not-matching-search
+          :view="view"
+          :fields="searchableFields"
+          @refresh="handleSearchChange"
+        ></ViewSearch>
+      </li>
+    </ul>
+  </div>
+</template>
+
+<script>
+import ViewFilter from '@baserow/modules/database/components/view/ViewFilter'
+import ViewSort from '@baserow/modules/database/components/view/ViewSort'
+import ViewSearch from '@baserow/modules/database/components/view/ViewSearch'
+import { getFilters, getOrderBy } from '@baserow/modules/database/utils/view'
+
+export default {
+  components: { ViewSearch, ViewSort, ViewFilter },
+  props: {
+    /**
+     * An array of filterable, sortable and searchable *schema* properties.
+     * To access the Baserow field response, these need to be reduced down
+     * to just their `metadata`. This happens in the `computed` methods below.
+     */
+    filterableProperties: {
+      type: Array,
+      required: true,
+    },
+    sortableProperties: {
+      type: Array,
+      required: true,
+    },
+    searchableProperties: {
+      type: Array,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      view: {
+        filters: [],
+        sortings: [],
+        filter_groups: [],
+        filter_type: 'AND',
+        filters_disabled: false,
+        _: { loading: false },
+      },
+    }
+  },
+  computed: {
+    filterableFields() {
+      return this.filterableProperties.map((prop) => prop.metadata)
+    },
+    sortableFields() {
+      return this.sortableProperties.map((prop) => prop.metadata)
+    },
+    searchableFields() {
+      return this.searchableProperties.map((prop) => prop.metadata)
+    },
+  },
+  methods: {
+    handleFiltersChange() {
+      this.$emit('filters-changed', getFilters(this.view, true))
+    },
+    handleSortingsChange() {
+      this.$emit('sortings-changed', getOrderBy(this.view, true))
+    },
+    handleSearchChange(value) {
+      this.$emit('search-changed', value.activeSearchTerm)
+    },
+  },
+}
+</script>
diff --git a/web-frontend/modules/integrations/serviceTypes.js b/web-frontend/modules/integrations/serviceTypes.js
index 49f963d2c..cf04dfb32 100644
--- a/web-frontend/modules/integrations/serviceTypes.js
+++ b/web-frontend/modules/integrations/serviceTypes.js
@@ -3,6 +3,7 @@ import { LocalBaserowIntegrationType } from '@baserow/modules/integrations/integ
 import LocalBaserowGetRowForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowGetRowForm'
 import LocalBaserowListRowsForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowListRowsForm'
 import { uuid } from '@baserow/modules/core/utils/string'
+import LocalBaserowAdhocHeader from '@baserow/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader'
 
 export class LocalBaserowGetRowServiceType extends ServiceType {
   static getType() {
@@ -67,12 +68,12 @@ export class LocalBaserowGetRowServiceType extends ServiceType {
 
     const databases = integration.context_data?.databases
 
-    if (service.table_id && databases) {
-      const tableSelected = databases
-        .map((database) => database.tables)
-        .flat()
-        .find(({ id }) => id === service.table_id)
+    const tableSelected = databases
+      .map((database) => database.tables)
+      .flat()
+      .find(({ id }) => id === service.table_id)
 
+    if (service.table_id && tableSelected) {
       return `${this.name} - ${tableSelected.name}`
     }
 
@@ -106,6 +107,13 @@ export class LocalBaserowListRowsServiceType extends ServiceType {
     return LocalBaserowListRowsForm
   }
 
+  /**
+   * The Local Baserow adhoc filtering, sorting and searching component.
+   */
+  get adhocHeaderComponent() {
+    return LocalBaserowAdhocHeader
+  }
+
   isValid(service) {
     return super.isValid(service) && Boolean(service.table_id)
   }
@@ -199,12 +207,12 @@ export class LocalBaserowListRowsServiceType extends ServiceType {
 
     const databases = integration.context_data?.databases
 
-    if (service.table_id && databases) {
-      const tableSelected = databases
-        .map((database) => database.tables)
-        .flat()
-        .find(({ id }) => id === service.table_id)
+    const tableSelected = databases
+      .map((database) => database.tables)
+      .flat()
+      .find(({ id }) => id === service.table_id)
 
+    if (service.table_id && tableSelected) {
       return `${this.name} - ${tableSelected.name}`
     }
 
diff --git a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap
index 5a8ab4ee1..801f87a31 100644
--- a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap
+++ b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap
@@ -47,10 +47,17 @@ exports[`ChoiceElement as default 1`] = `
                
               <ul
                 class="select__items"
+                style="display: none;"
                 tabindex="-1"
               />
                
-              <!---->
+              <div
+                class="select__items--empty"
+              >
+                
+        dropdown.empty
+      
+              </div>
                
               <!---->
             </div>
@@ -180,6 +187,7 @@ exports[`ChoiceElement as manual dropdown 1`] = `
                
               <ul
                 class="select__items"
+                style=""
                 tabindex="-1"
               >
                 <li
diff --git a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap
index e8d05daec..1dcd2050f 100644
--- a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap
+++ b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap
@@ -49,9 +49,10 @@ exports[`RecordSelectorElement does not paginate if API returns 400/404 1`] = `
                
               <ul
                 class="select__items"
+                style=""
                 tabindex="-1"
               >
-                  
+                   
                 <section
                   class="infinite-scroll"
                 >
@@ -263,9 +264,10 @@ exports[`RecordSelectorElement does not paginate if API returns 400/404 2`] = `
                
               <ul
                 class="select__items"
+                style=""
                 tabindex="-1"
               >
-                  
+                   
                 <section
                   class="infinite-scroll"
                 >
@@ -477,9 +479,10 @@ exports[`RecordSelectorElement does not paginate if API returns 400/404 3`] = `
                
               <ul
                 class="select__items"
+                style=""
                 tabindex="-1"
               >
-                  
+                   
                 <section
                   class="infinite-scroll"
                 >
@@ -691,9 +694,10 @@ exports[`RecordSelectorElement resolves suffix formulas 1`] = `
                
               <ul
                 class="select__items"
+                style=""
                 tabindex="-1"
               >
-                  
+                   
                 <section
                   class="infinite-scroll"
                 >
@@ -827,9 +831,10 @@ exports[`RecordSelectorElement resolves suffix formulas 2`] = `
                
               <ul
                 class="select__items"
+                style=""
                 tabindex="-1"
               >
-                  
+                   
                 <section
                   class="infinite-scroll"
                 >
diff --git a/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap b/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap
index aefbbba16..1459ae519 100644
--- a/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap
+++ b/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap
@@ -44,6 +44,7 @@ exports[`Dropdown component Test slots 1`] = `
      
     <ul
       class="select__items"
+      style=""
       tabindex="-1"
     >
       <li
@@ -114,6 +115,7 @@ exports[`Dropdown component With items 1`] = `
      
     <ul
       class="select__items"
+      style=""
       tabindex="-1"
     >
       <li
@@ -199,6 +201,7 @@ exports[`Dropdown component With items 2`] = `
      
     <ul
       class="select__items"
+      style=""
       tabindex="-1"
     >
       <li
@@ -510,10 +513,17 @@ exports[`Dropdown component basics 1`] = `
      
     <ul
       class="select__items"
+      style="display: none;"
       tabindex="-1"
     />
      
-    <!---->
+    <div
+      class="select__items--empty"
+    >
+      
+        dropdown.empty
+      
+    </div>
      
     <!---->
   </div>
@@ -546,10 +556,17 @@ exports[`Dropdown component basics 2`] = `
      
     <ul
       class="select__items"
+      style="display: none;"
       tabindex="-1"
     />
      
-    <!---->
+    <div
+      class="select__items--empty"
+    >
+      
+        dropdown.empty
+      
+    </div>
      
     <!---->
   </div>
@@ -595,10 +612,17 @@ exports[`Dropdown component basics 3`] = `
      
     <ul
       class="select__items"
+      style="display: none;"
       tabindex="-1"
     />
      
-    <!---->
+    <div
+      class="select__items--empty"
+    >
+      
+        dropdown.empty
+      
+    </div>
      
     <!---->
   </div>
@@ -649,6 +673,7 @@ exports[`Dropdown component change value prop 1`] = `
      
     <ul
       class="select__items"
+      style=""
       tabindex="-1"
     >
       <li
@@ -765,6 +790,7 @@ exports[`Dropdown component test focus 1`] = `
      
     <ul
       class="select__items prevent-scroll"
+      style=""
       tabindex="-1"
     >
       <li
@@ -850,6 +876,7 @@ exports[`Dropdown component test focus 2`] = `
      
     <ul
       class="select__items prevent-scroll"
+      style=""
       tabindex="-1"
     >
       <li
@@ -935,6 +962,7 @@ exports[`Dropdown component test interactions 1`] = `
      
     <ul
       class="select__items prevent-scroll"
+      style=""
       tabindex="-1"
     >
       <li
@@ -1051,6 +1079,7 @@ exports[`Dropdown component test interactions 2`] = `
      
     <ul
       class="select__items prevent-scroll"
+      style=""
       tabindex="-1"
     >
       <li
diff --git a/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap b/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap
index baa547c8c..44a58bd2b 100644
--- a/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap
+++ b/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap
@@ -95,6 +95,7 @@ exports[`Preview exportTableModal Modal with no view 1`] = `
                        
                       <ul
                         class="select__items"
+                        style=""
                         tabindex="-1"
                       >
                         <li
@@ -314,6 +315,7 @@ exports[`Preview exportTableModal Modal with no view 1`] = `
                          
                         <ul
                           class="select__items"
+                          style=""
                           tabindex="-1"
                         >
                           <li
@@ -589,6 +591,7 @@ exports[`Preview exportTableModal Modal with no view 1`] = `
                          
                         <ul
                           class="select__items"
+                          style=""
                           tabindex="-1"
                         >
                           <li
@@ -1874,6 +1877,7 @@ exports[`Preview exportTableModal Modal with view 1`] = `
                        
                       <ul
                         class="select__items"
+                        style=""
                         tabindex="-1"
                       >
                         <li
@@ -2093,6 +2097,7 @@ exports[`Preview exportTableModal Modal with view 1`] = `
                          
                         <ul
                           class="select__items"
+                          style=""
                           tabindex="-1"
                         >
                           <li
@@ -2368,6 +2373,7 @@ exports[`Preview exportTableModal Modal with view 1`] = `
                          
                         <ul
                           class="select__items"
+                          style=""
                           tabindex="-1"
                         >
                           <li
diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap
index 124461a5d..245957739 100644
--- a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap
+++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap
@@ -146,6 +146,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -293,6 +294,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -653,6 +655,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = `
                
               <ul
                 class="select__items select__items--no-max-height"
+                style=""
                 tabindex="-1"
               >
                 <li
@@ -776,6 +779,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -923,6 +927,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -1291,6 +1296,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 1`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -1439,6 +1445,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 1`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height prevent-scroll"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -1807,6 +1814,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 2`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height"
+                  style=""
                   tabindex="-1"
                 >
                   <li
@@ -1955,6 +1963,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 2`] = `
                  
                 <ul
                   class="select__items select__items--no-max-height prevent-scroll"
+                  style=""
                   tabindex="-1"
                 >
                   <li