From 332ec39ef64f7883e63bb4efc30bbc2501932ba8 Mon Sep 17 00:00:00 2001
From: Evren Ozkan <xx@evren.io>
Date: Mon, 31 Mar 2025 09:55:57 +0000
Subject: [PATCH] Add array support to query parameters validation handlers

---
 .../integrations/local_baserow/mixins.py      |   2 +-
 .../test_list_rows_service_type.py            |  61 ++++++
 .../3433_query_params_array_support.json      |   8 +
 e2e-tests/tests/builder/builderPage.spec.ts   |  88 +++++---
 .../elements/components/ChoiceElement.vue     |  41 ++--
 .../components/page/PreviewNavigationBar.vue  |  45 ++--
 .../page/PreviewNavigationBarInput.vue        |   8 +-
 .../page/PreviewNavigationBarQueryParam.vue   |  48 -----
 .../modules/builder/dataProviderTypes.js      |   4 +-
 web-frontend/modules/builder/elementTypes.js  |  32 ++-
 web-frontend/modules/builder/enums.js         |  19 +-
 .../modules/builder/pages/publicPage.vue      |  10 +-
 web-frontend/modules/core/utils/validator.js  |  29 ++-
 .../test/unit/core/utils/validator.spec.js    | 195 +++++++++++++++++-
 14 files changed, 461 insertions(+), 129 deletions(-)
 create mode 100644 changelog/entries/unreleased/feature/3433_query_params_array_support.json
 delete mode 100644 web-frontend/modules/builder/components/page/PreviewNavigationBarQueryParam.vue

diff --git a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py
index 343dbe33e..f6c11b86e 100644
--- a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py
+++ b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py
@@ -172,7 +172,7 @@ class LocalBaserowTableServiceFilterableMixin:
 
             if service_filter.value_is_formula:
                 try:
-                    resolved_value = str(
+                    resolved_value = ensure_string(
                         resolve_formula(
                             service_filter.value,
                             formula_runtime_function_registry,
diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py
index 886811cea..b4ba0257c 100644
--- a/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py
+++ b/backend/tests/baserow/contrib/integrations/local_baserow/service_types/test_list_rows_service_type.py
@@ -11,6 +11,7 @@ from baserow.contrib.database.api.rows.serializers import RowSerializer
 from baserow.contrib.database.rows.handler import RowHandler
 from baserow.contrib.database.table.handler import TableHandler
 from baserow.contrib.database.views.models import SORT_ORDER_ASC, SORT_ORDER_DESC
+from baserow.contrib.database.views.view_filters import MultipleSelectHasViewFilterType
 from baserow.contrib.integrations.local_baserow.models import LocalBaserowListRows
 from baserow.contrib.integrations.local_baserow.service_types import (
     LocalBaserowListRowsUserServiceType,
@@ -1162,3 +1163,63 @@ def test_extract_properties(path, expected):
     result = service_type.extract_properties(path)
 
     assert result == expected
+
+
+@pytest.mark.django_db(transaction=True)
+def test_search_on_multiple_select_with_list(data_fixture):
+    user = data_fixture.create_user()
+    workspace = data_fixture.create_workspace(user=user)
+    database = data_fixture.create_database_application(workspace=workspace)
+    page = data_fixture.create_builder_page(
+        user=user, path="/page/", query_params=[{"name": "foobar", "type": "numeric"}]
+    )
+
+    service_type = service_type_registry.get("local_baserow_list_rows")
+    integration = data_fixture.create_local_baserow_integration(
+        application=page.builder, user=user
+    )
+    table = data_fixture.create_database_table(database=database)
+    view = data_fixture.create_grid_view(user, table=table)
+    option_field = data_fixture.create_multiple_select_field(
+        table=table, name="option_field", order=1, primary=True
+    )
+    options = [
+        data_fixture.create_select_option(
+            field=option_field, value="A", color="AAA", order=0
+        ),
+        data_fixture.create_select_option(
+            field=option_field, value="B", color="BBB", order=0
+        ),
+        data_fixture.create_select_option(
+            field=option_field, value="C", color="CCC", order=0
+        ),
+    ]
+    table_rows = data_fixture.create_rows_in_table(
+        table=table,
+        rows=[[(options[0].id,)], [(options[1].id,)], [(options[1].id,)]],
+        fields=[option_field],
+    )
+
+    service = data_fixture.create_local_baserow_list_rows_service(
+        integration=integration,
+        view=view,
+        table=view.table,
+        search_query="",
+        filter_type="OR",
+    )
+
+    data_fixture.create_local_baserow_table_service_filter(
+        service=service,
+        field=option_field,
+        value="get('foobar')",
+        order=0,
+        type=MultipleSelectHasViewFilterType.type,
+        value_is_formula=True,
+    )
+
+    dispatch_context = FakeDispatchContext(context={"foobar": [options[0].id]})
+    dispatch_data = service_type.dispatch_data(
+        service, {"table": table}, dispatch_context
+    )
+    assert len(dispatch_data["results"]) == 1
+    assert dispatch_data["results"][0]._primary_field_id == options[0].field_id
diff --git a/changelog/entries/unreleased/feature/3433_query_params_array_support.json b/changelog/entries/unreleased/feature/3433_query_params_array_support.json
new file mode 100644
index 000000000..69025fbef
--- /dev/null
+++ b/changelog/entries/unreleased/feature/3433_query_params_array_support.json
@@ -0,0 +1,8 @@
+{
+  "type": "feature",
+  "message": "Add support for list type values for query parameters.",
+  "domain": "builder",
+  "issue_number": 3433,
+  "bullet_points": [],
+  "created_at": "2025-02-20"
+}
diff --git a/e2e-tests/tests/builder/builderPage.spec.ts b/e2e-tests/tests/builder/builderPage.spec.ts
index 3eb4e53ec..d2a54b615 100644
--- a/e2e-tests/tests/builder/builderPage.spec.ts
+++ b/e2e-tests/tests/builder/builderPage.spec.ts
@@ -1,11 +1,11 @@
-import {expect, test} from "../baserowTest";
+import { expect, test } from "../baserowTest";
 
 test.describe("Builder page test suite", () => {
-  test.beforeEach(async ({builderPagePage}) => {
+  test.beforeEach(async ({ builderPagePage }) => {
     await builderPagePage.goto();
   });
 
-  test("Can create a page", async ({page}) => {
+  test("Can create a page", async ({ page }) => {
     await page.getByText("New page").click();
     await page.getByText("Create page").waitFor();
     await page
@@ -25,12 +25,12 @@ test.describe("Builder page test suite", () => {
     ).toBeVisible();
   });
 
-  test("Can open page settings", async ({page}) => {
+  test("Can open page settings", async ({ page }) => {
     await page.getByText("Page settings").click();
     await expect(page.locator(".box__title").getByText("Page")).toBeVisible();
   });
 
-  test("Can change page settings", async ({page}) => {
+  test("Can change page settings", async ({ page }) => {
     await page.getByText("Page settings").click();
 
     await page
@@ -57,9 +57,9 @@ test.describe("Builder page test suite", () => {
     ).toBeVisible();
   });
 
-  test("Can create an element from empty page", async ({page}) => {
+  test("Can create an element from empty page", async ({ page }) => {
     await page.getByText("Click to create an element").click();
-    await page.getByText("Heading", {exact: true}).click();
+    await page.getByText("Heading", { exact: true }).click();
 
     await expect(
       page.locator(".modal__box").getByText("Add new element")
@@ -69,13 +69,13 @@ test.describe("Builder page test suite", () => {
     ).toBeVisible();
   });
 
-  test("Can create an element from element menu", async ({page}) => {
+  test("Can create an element from element menu", async ({ page }) => {
     await page.locator(".header").getByText("Elements").click();
     await page
       .locator(".elements-context")
-      .getByText("Element", {exact: true})
+      .getByText("Element", { exact: true })
       .click();
-    await page.getByText("Heading", {exact: true}).click();
+    await page.getByText("Heading", { exact: true }).click();
 
     await expect(
       page.locator(".modal__box").getByText("Add new element")
@@ -85,8 +85,7 @@ test.describe("Builder page test suite", () => {
     ).toBeVisible();
   });
 
-
-  test("Can add query parameter to page setting", async ({page}) => {
+  test("Can add query parameter to page setting", async ({ page }) => {
     await page.getByText("Page settings").click();
 
     await page
@@ -94,13 +93,33 @@ test.describe("Builder page test suite", () => {
       .getByPlaceholder("Enter a name...")
       .fill("New page name");
 
-    await page.getByRole('button', {name: 'Add query string parameter'}).click();
+    await page
+      .getByRole("button", { name: "Add query string parameter" })
+      .click();
+    await page
+      .getByRole("button", { name: "Add another query string parameter" })
+      .click();
 
     await page
       .locator(".page-settings-query-params .form-input__wrapper")
-      .getByRole('textbox')
+      .getByRole("textbox")
+      .nth(1)
       .fill("my_param");
 
+    await page
+      .locator(".page-settings-query-params .form-input__wrapper")
+      .getByRole("textbox")
+      .nth(0)
+      .fill("my_param2");
+
+    await page.locator("a").filter({ hasText: "Text" }).first().click();
+    await page
+      .locator("form")
+      .getByRole("list")
+      .locator("a")
+      .filter({ hasText: "Numeric" })
+      .click();
+
     await page.locator(".button").getByText("Save").click();
     await expect(
       page.getByText("The page settings have been updated.")
@@ -108,20 +127,33 @@ test.describe("Builder page test suite", () => {
 
     await page.getByTitle("Close").click();
     await expect(page.locator(".box__title").getByText("Page")).toBeHidden();
-    await page.getByText('Click to create an element').click();
-    await page.locator('.add-element-card__element-type-icon-link').click();
-    await page.getByLabel('my_param=').fill("foo")
-    await page.getByRole('complementary').getByRole('textbox').click();
-    await page.getByRole('complementary').getByRole('textbox').locator('div').first().fill('linkim');
-    await page.locator('a').filter({hasText: 'Make a choice'}).click();
-    await page.locator('a').filter({hasText: '?my_param=*'}).click();
+    await page.getByText("Click to create an element").click();
+    await page.locator(".add-element-card__element-type-icon-link").click();
+    await page.getByLabel("my_param=").fill("foo");
+    await page.getByLabel("my_param2=").fill("15");
+    await page.getByRole("complementary").getByRole("textbox").click();
+    await page
+      .getByRole("complementary")
+      .getByRole("textbox")
+      .locator("div")
+      .first()
+      .fill("linkim");
+    await page.locator("a").filter({ hasText: "Make a choice" }).click();
+    await page
+      .locator("a")
+      .filter({ hasText: "New page name /default/page?" })
+      .click();
     // click empty place to close tooltip from prev. step
-    await page.click('body')
-    await page.getByRole('textbox').nth(2).click();
-    await page.getByText('my_param', { exact: true }).first().click();
-    await page.click('body')
-    await expect(page.getByRole('link', {name: 'linkim'})).toHaveAttribute(
-      'href', /\?my_param=foo/);
-
+    await page.click("body");
+    await page.getByRole("textbox").nth(4).click();
+    await page.getByText("my_param", { exact: true }).first().click();
+    await page.locator('[id="__layout"]').getByRole("textbox").nth(3).click();
+    await page.getByText("my_param2", { exact: true }).first().click();
+    await page.click("body");
+    await expect(page.getByRole("link", { name: "linkim" })).toHaveAttribute(
+      "href",
+      /\?my_param2=15&my_param=foo/
+    );
+    debugger;
   });
 });
diff --git a/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue b/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue
index 6c8aa3f56..0054f0ad4 100644
--- a/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue
+++ b/web-frontend/modules/builder/components/elements/components/ChoiceElement.vue
@@ -61,6 +61,7 @@ import {
   ensureString,
   ensureStringOrInteger,
   ensureArray,
+  ensurePositiveInteger,
 } from '@baserow/modules/core/utils/validator'
 import { CHOICE_OPTION_TYPES } from '@baserow/modules/builder/enums'
 
@@ -94,20 +95,36 @@ export default {
       return ensureString(this.resolveFormula(this.element.placeholder))
     },
     defaultValueResolved() {
+      let converter = ensureString
+      if (
+        this.optionsResolved.find(
+          ({ value }) => value !== undefined && value !== null // We skip null values
+        ) &&
+        Number.isInteger(this.optionsResolved[0].value)
+      ) {
+        converter = (v) => ensurePositiveInteger(v, { allowNull: true })
+      }
       if (this.element.multiple) {
-        const existingValues = this.optionsResolved.map(({ value }) => value)
-        return ensureArray(this.resolveFormula(this.element.default_value))
-          .map(ensureStringOrInteger)
-          .filter((value) => existingValues.includes(value))
+        try {
+          const existingValues = this.optionsResolved.map(({ value }) => value)
+          return ensureArray(this.resolveFormula(this.element.default_value))
+            .map(converter)
+            .filter((value) => existingValues.includes(value))
+        } catch {
+          return []
+        }
       } else {
-        // Always return a string if we have a default value, otherwise
-        // set the value to null as single select fields will only skip
-        // field preparation if the value is null.
-        const resolvedSingleValue = ensureStringOrInteger(
-          this.resolveFormula(this.element.default_value)
-        )
-
-        return resolvedSingleValue === '' ? null : resolvedSingleValue
+        try {
+          // Always return a string if we have a default value, otherwise
+          // set the value to null as single select fields will only skip
+          // field preparation if the value is null.
+          const resolvedSingleValue = converter(
+            this.resolveFormula(this.element.default_value)
+          )
+          return resolvedSingleValue === '' ? null : resolvedSingleValue
+        } catch {
+          return null
+        }
       }
     },
     canHaveOptions() {
diff --git a/web-frontend/modules/builder/components/page/PreviewNavigationBar.vue b/web-frontend/modules/builder/components/page/PreviewNavigationBar.vue
index b2013cb41..95405eaac 100644
--- a/web-frontend/modules/builder/components/page/PreviewNavigationBar.vue
+++ b/web-frontend/modules/builder/components/page/PreviewNavigationBar.vue
@@ -35,21 +35,22 @@
           class="preview-navigation-bar__query-separator"
         >
           {{ index === 0 ? '?' : '&' }}
-        </span>
-        <PreviewNavigationBarQueryParam
-          :key="`param-${queryParam.key}`"
-          :class="`preview-navigation-bar__query-parameter-input--${queryParam.type}`"
-          :validation-fn="queryParam.validationFn"
-          :default-value="pageParameters[queryParam.name]"
-          :name="queryParam.name"
-          @change="
-            actionSetParameter({
-              page,
-              name: queryParam.name,
-              value: $event,
-            })
-          "
-        />
+
+          <label :for="queryParam.name">{{ queryParam.name }}=</label>
+          <PreviewNavigationBarInput
+            :id="queryParam.name"
+            :key="`param-${queryParam.key}`"
+            :class="`preview-navigation-bar__query-parameter-input--${queryParam.type}`"
+            :validation-fn="queryParam.validationFn"
+            :default-value="pageParameters[queryParam.name]"
+            @change="
+              actionSetParameter({
+                page,
+                name: queryParam.name,
+                value: $event,
+              })
+            "
+        /></span>
       </template>
     </div>
     <div />
@@ -61,15 +62,13 @@ import { splitPath } from '@baserow/modules/builder/utils/path'
 import PreviewNavigationBarInput from '@baserow/modules/builder/components/page/PreviewNavigationBarInput'
 import UserSelector from '@baserow/modules/builder/components/page/UserSelector'
 import { mapActions } from 'vuex'
-import { PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS } from '@baserow/modules/builder/enums'
-import PreviewNavigationBarQueryParam from '@baserow/modules/builder/components/page/PreviewNavigationBarQueryParam.vue'
+import {
+  PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS,
+  QUERY_PARAM_TYPE_HANDLER_FUNCTIONS,
+} from '@baserow/modules/builder/enums'
 
 export default {
-  components: {
-    PreviewNavigationBarInput,
-    UserSelector,
-    PreviewNavigationBarQueryParam,
-  },
+  components: { PreviewNavigationBarInput, UserSelector },
   props: {
     page: {
       type: Object,
@@ -84,7 +83,7 @@ export default {
       return this.page.query_params.map((queryParam, idx) => ({
         ...queryParam,
         key: `query-param-${queryParam.name}-${idx}`,
-        validationFn: PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS[queryParam.type],
+        validationFn: QUERY_PARAM_TYPE_HANDLER_FUNCTIONS[queryParam.type],
       }))
     },
     splitPath() {
diff --git a/web-frontend/modules/builder/components/page/PreviewNavigationBarInput.vue b/web-frontend/modules/builder/components/page/PreviewNavigationBarInput.vue
index 59d3a2b60..dc1a156da 100644
--- a/web-frontend/modules/builder/components/page/PreviewNavigationBarInput.vue
+++ b/web-frontend/modules/builder/components/page/PreviewNavigationBarInput.vue
@@ -9,10 +9,12 @@
 </template>
 
 <script>
+import _ from 'lodash'
+
 export default {
   props: {
     defaultValue: {
-      type: [String, Number],
+      type: [String, Number, Array],
       required: false,
       default: '',
     },
@@ -45,7 +47,9 @@ export default {
   },
   watch: {
     defaultValue(newValue) {
-      this.inputValue = newValue
+      if (!_.isEqual(this.inputValue, newValue)) {
+        this.inputValue = newValue
+      }
     },
   },
 }
diff --git a/web-frontend/modules/builder/components/page/PreviewNavigationBarQueryParam.vue b/web-frontend/modules/builder/components/page/PreviewNavigationBarQueryParam.vue
deleted file mode 100644
index 824f5f35e..000000000
--- a/web-frontend/modules/builder/components/page/PreviewNavigationBarQueryParam.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<template>
-  <div class="preview-navigation-bar-param">
-    <label :for="name" class="preview-navigation-bar-param__label">
-      {{ name }}=</label
-    ><input
-      :id="name"
-      v-model="inputValue"
-      class="preview-navigation-bar-input"
-    />
-  </div>
-</template>
-
-<script>
-export default {
-  props: {
-    defaultValue: {
-      type: [String, Number],
-      required: false,
-      default: '',
-    },
-    name: {
-      type: String,
-      required: true,
-    },
-  },
-  data() {
-    return {
-      value: this.defaultValue,
-    }
-  },
-  computed: {
-    inputValue: {
-      get() {
-        return this.value
-      },
-      set(inputValue) {
-        this.value = inputValue
-        this.$emit('change', this.value)
-      },
-    },
-  },
-  watch: {
-    defaultValue(newValue) {
-      this.value = newValue
-    },
-  },
-}
-</script>
diff --git a/web-frontend/modules/builder/dataProviderTypes.js b/web-frontend/modules/builder/dataProviderTypes.js
index 7cea464d4..11011eb7a 100644
--- a/web-frontend/modules/builder/dataProviderTypes.js
+++ b/web-frontend/modules/builder/dataProviderTypes.js
@@ -6,7 +6,7 @@ import { defaultValueForParameterType } from '@baserow/modules/builder/utils/par
 import { DEFAULT_USER_ROLE_PREFIX } from '@baserow/modules/builder/constants'
 import {
   PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS,
-  QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS,
+  QUERY_PARAM_TYPE_HANDLER_FUNCTIONS,
 } from '@baserow/modules/builder/enums'
 import { extractSubSchema } from '@baserow/modules/core/utils/schema'
 
@@ -311,7 +311,7 @@ export class PageParameterDataProviderType extends DataProviderType {
       await Promise.all(
         pageParams.map(({ name, type }) => {
           const validators = queryParamNames.includes(name)
-            ? QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS
+            ? QUERY_PARAM_TYPE_HANDLER_FUNCTIONS
             : PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS
           let value
           try {
diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js
index a470219ae..506225b4c 100644
--- a/web-frontend/modules/builder/elementTypes.js
+++ b/web-frontend/modules/builder/elementTypes.js
@@ -1497,17 +1497,45 @@ export class ChoiceElementType extends FormElementType {
     return element.multiple ? 'array' : 'string'
   }
 
+  /**
+   * Returns the first option for this element.
+   * @param {Object} element the element we want the option for.
+   * @returns the first option value.
+   */
+  _getFirstOptionValue(element) {
+    switch (element.option_type) {
+      case CHOICE_OPTION_TYPES.MANUAL:
+        return element.options.find(({ value }) => value)
+      case CHOICE_OPTION_TYPES.FORMULAS: {
+        const formulaValues = ensureArray(
+          this.resolveFormula(this.element.formula_value)
+        )
+        if (formulaValues.length === 0) {
+          return null
+        }
+        return ensureStringOrInteger(formulaValues[0])
+      }
+      default:
+        return []
+    }
+  }
+
   getInitialFormDataValue(element, applicationContext) {
     try {
+      const firstValue = this._getFirstOptionValue(element)
+      let converter = ensureStringOrInteger
+      if (firstValue ?? Number.isInteger(firstValue)) {
+        converter = (v) => ensurePositiveInteger(v, { allowNull: true })
+      }
       if (element.multiple) {
         return ensureArray(
           this.resolveFormula(element.default_value, {
             element,
             ...applicationContext,
           })
-        ).map(ensureStringOrInteger)
+        ).map(converter)
       } else {
-        return ensureStringOrInteger(
+        return converter(
           this.resolveFormula(element.default_value, {
             element,
             ...applicationContext,
diff --git a/web-frontend/modules/builder/enums.js b/web-frontend/modules/builder/enums.js
index 2becef7dc..3f94391b6 100644
--- a/web-frontend/modules/builder/enums.js
+++ b/web-frontend/modules/builder/enums.js
@@ -2,6 +2,8 @@ import {
   ensureString,
   ensureNonEmptyString,
   ensurePositiveInteger,
+  ensureArray,
+  ensureNumeric,
 } from '@baserow/modules/core/utils/validator'
 import {
   DataSourceDataProviderType,
@@ -23,9 +25,20 @@ export const PAGE_PARAM_TYPE_VALIDATION_FUNCTIONS = {
   numeric: ensurePositiveInteger,
   text: ensureNonEmptyString,
 }
-export const QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS = {
-  numeric: (n) => ensurePositiveInteger(n, { allowNull: true }),
-  text: ensureString,
+export const QUERY_PARAM_TYPE_HANDLER_FUNCTIONS = {
+  numeric: (input) => {
+    const value = ensureArray(input, { allowEmpty: true }).map((i) =>
+      ensureNumeric(i, { allowNull: true })
+    )
+
+    return value.length === 0 ? null : value.length === 1 ? value[0] : value
+  },
+  text: (input) => {
+    const value = ensureArray(input, {
+      allowEmpty: true,
+    }).map((i) => ensureString(i, { allowEmpty: true }))
+    return value.length === 0 ? null : value.length === 1 ? value[0] : value
+  },
 }
 
 export const ALLOWED_LINK_PROTOCOLS = [
diff --git a/web-frontend/modules/builder/pages/publicPage.vue b/web-frontend/modules/builder/pages/publicPage.vue
index b0ac087d3..908d04709 100644
--- a/web-frontend/modules/builder/pages/publicPage.vue
+++ b/web-frontend/modules/builder/pages/publicPage.vue
@@ -27,7 +27,7 @@ import {
   userSourceCookieTokenName,
   setToken,
 } from '@baserow/modules/core/utils/auth'
-import { QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS } from '@baserow/modules/builder/enums'
+import { QUERY_PARAM_TYPE_HANDLER_FUNCTIONS } from '@baserow/modules/builder/enums'
 
 const logOffAndReturnToLogin = async ({ builder, store, redirect }) => {
   await store.dispatch('userSourceUser/logoff', {
@@ -41,6 +41,7 @@ const logOffAndReturnToLogin = async ({ builder, store, redirect }) => {
 }
 
 export default {
+  name: 'PublicPage',
   components: { PageContent, Toasts },
   provide() {
     return {
@@ -350,12 +351,11 @@ export default {
         // update the page's query parameters in the store
         Promise.all(
           this.currentPage.query_params.map(({ name, type }) => {
-            if (!newQuery[name]) return null
             let value
             try {
-              value = QUERY_PARAM_TYPE_VALIDATION_FUNCTIONS[type](
-                newQuery[name]
-              )
+              if (newQuery[name]) {
+                value = QUERY_PARAM_TYPE_HANDLER_FUNCTIONS[type](newQuery[name])
+              }
             } catch {
               // Skip setting the parameter if the user-provided value
               // doesn't pass our parameter `type` validation.
diff --git a/web-frontend/modules/core/utils/validator.js b/web-frontend/modules/core/utils/validator.js
index d99019ffb..287aeaf26 100644
--- a/web-frontend/modules/core/utils/validator.js
+++ b/web-frontend/modules/core/utils/validator.js
@@ -4,10 +4,35 @@ import { trueValues, falseValues } from '@baserow/modules/core/utils/constants'
 import { DATE_FORMATS } from '@baserow/modules/builder/enums'
 import moment from '@baserow/modules/core/moment'
 
+/**
+ * Ensures that the value is a Numeral or can be converted to a numeric value.
+ * @param {number|string} value - The value to ensure as a number.
+ * @param allowNull {boolean} - Whether to allow null or empty values.
+ * @returns {number|null} The value as a Number if conversion is successful.
+ * @throws {Error} If the value is not a valid number or convertible to an number.
+ */
+export const ensureNumeric = (value, { allowNull = false } = {}) => {
+  if (allowNull && (value === null || value === '')) {
+    return null
+  }
+  if (Number.isFinite(value)) {
+    return value
+  }
+  if (typeof value === 'string' || value instanceof String) {
+    if (/^([-+])?(\d+(\.\d+)?)$/.test(value)) {
+      return Number(value)
+    }
+  }
+  throw new Error(
+    `Value '${value}' is not a valid number or convertible to a number.`
+  )
+}
+
 /**
  * Ensures that the value is an integer or can be converted to an integer.
  * @param {number|string} value - The value to ensure as an integer.
- * @returns {number} The value as an integer if conversion is successful.
+ * @param allowNull {boolean} - Whether to allow null or empty values.
+ * @returns {number|null} The value as an integer if conversion is successful, null otherwise.
  * @throws {Error} If the value is not a valid integer or convertible to an integer.
  */
 export const ensureInteger = (value) => {
@@ -34,7 +59,7 @@ export const ensureInteger = (value) => {
  * @throws {Error} If the value is not a valid non-negative integer
  */
 export const ensurePositiveInteger = (value, { allowNull = false } = {}) => {
-  if (allowNull && value === null) {
+  if (allowNull && (value === null || value === '')) {
     return null
   }
   const validInteger = ensureInteger(value)
diff --git a/web-frontend/test/unit/core/utils/validator.spec.js b/web-frontend/test/unit/core/utils/validator.spec.js
index 126ddbe2c..42efc1df8 100644
--- a/web-frontend/test/unit/core/utils/validator.spec.js
+++ b/web-frontend/test/unit/core/utils/validator.spec.js
@@ -3,11 +3,15 @@ import {
   ensureDateTime,
   ensureInteger,
   ensureString,
+  ensureNumeric,
   ensureStringOrInteger,
   ensurePositiveInteger,
 } from '@baserow/modules/core/utils/validator'
 import { expect } from '@jest/globals'
-import { DATE_FORMATS } from '@baserow/modules/builder/enums'
+import {
+  DATE_FORMATS,
+  QUERY_PARAM_TYPE_HANDLER_FUNCTIONS,
+} from '@baserow/modules/builder/enums'
 
 describe('ensureInteger', () => {
   it('should return the value as an integer if it is already an integer', () => {
@@ -29,6 +33,130 @@ describe('ensureInteger', () => {
   })
 })
 
+describe('ensurePositiveInteger', () => {
+  // Valid positive integers
+  test('returns the same value for positive integers', () => {
+    expect(ensurePositiveInteger(5)).toBe(5)
+    expect(ensurePositiveInteger(0)).toBe(0)
+    expect(ensurePositiveInteger(1000)).toBe(1000)
+  })
+
+  // String conversions
+  test('converts string representations of positive integers', () => {
+    expect(ensurePositiveInteger('42')).toBe(42)
+    expect(ensurePositiveInteger('0')).toBe(0)
+    expect(ensurePositiveInteger('+123')).toBe(123)
+  })
+
+  // Null handling
+  test('returns null when value is null and allowNull is true', () => {
+    expect(ensurePositiveInteger(null, { allowNull: true })).toBeNull()
+  })
+
+  test('returns null when value is empty string and allowNull is true', () => {
+    expect(ensurePositiveInteger('', { allowNull: true })).toBeNull()
+  })
+
+  // Error cases
+  test('throws error for negative integers', () => {
+    expect(() => ensurePositiveInteger(-5)).toThrow(
+      'Value is not a positive integer.'
+    )
+    expect(() => ensurePositiveInteger('-10')).toThrow(
+      'Value is not a positive integer.'
+    )
+  })
+
+  test('throws error for non-integer values', () => {
+    expect(() => ensurePositiveInteger('abc')).toThrow('not a valid integer')
+    expect(() => ensurePositiveInteger({})).toThrow('not a valid integer')
+    expect(() => ensurePositiveInteger([])).toThrow('not a valid integer')
+    expect(() => ensurePositiveInteger(3.14)).toThrow('not a valid integer')
+    expect(() => ensurePositiveInteger('3.14')).toThrow('not a valid integer')
+  })
+
+  test('throws error for null when allowNull is false', () => {
+    expect(() => ensurePositiveInteger(null)).toThrow('not a valid integer')
+  })
+
+  test('throws error for empty string when allowNull is false', () => {
+    expect(() => ensurePositiveInteger('')).toThrow('not a valid integer')
+  })
+
+  // Default behavior
+  test('allowNull defaults to false', () => {
+    expect(() => ensurePositiveInteger(null)).toThrow()
+    expect(() => ensurePositiveInteger('')).toThrow()
+  })
+})
+
+describe('ensureNumeric', () => {
+  // Test valid numeric inputs
+  test('returns the same value for valid numbers', () => {
+    expect(ensureNumeric(42)).toBe(42)
+    expect(ensureNumeric(0)).toBe(0)
+    expect(ensureNumeric(-10)).toBe(-10)
+    expect(ensureNumeric(3.14)).toBe(3.14)
+  })
+
+  // Test string conversion
+  test('converts valid numeric strings to numbers', () => {
+    expect(ensureNumeric('42')).toBe(42)
+    expect(ensureNumeric('0')).toBe(0)
+    expect(ensureNumeric('-10')).toBe(-10)
+    expect(ensureNumeric('3.14')).toBe(3.14)
+    expect(ensureNumeric('+42')).toBe(42)
+  })
+
+  // Test null handling
+  test('handles null values based on allowNull option', () => {
+    expect(() => ensureNumeric(null)).toThrow()
+    expect(ensureNumeric(null, { allowNull: true })).toBeNull()
+  })
+
+  // Test empty string handling
+  test('handles empty strings based on allowNull option', () => {
+    expect(() => ensureNumeric('')).toThrow()
+    expect(ensureNumeric('', { allowNull: true })).toBeNull()
+  })
+
+  // Test invalid inputs
+  test('throws error for non-numeric strings', () => {
+    expect(() => ensureNumeric('abc')).toThrow()
+    expect(() => ensureNumeric('123abc')).toThrow()
+    expect(() => ensureNumeric('abc123')).toThrow()
+    expect(() => ensureNumeric('12.34.56')).toThrow()
+    expect(() => ensureNumeric('Infinity')).toThrow()
+    expect(() => ensureNumeric('-Infinity')).toThrow()
+  })
+
+  test('throws error for objects and arrays', () => {
+    expect(() => ensureNumeric({})).toThrow()
+    expect(() => ensureNumeric([])).toThrow()
+    expect(() => ensureNumeric([1, 2, 3])).toThrow()
+  })
+
+  test('throws error for boolean values', () => {
+    expect(() => ensureNumeric(true)).toThrow()
+    expect(() => ensureNumeric(false)).toThrow()
+  })
+
+  test('throws error for undefined', () => {
+    expect(() => ensureNumeric(undefined)).toThrow()
+    expect(() => ensureNumeric(undefined, { allowNull: true })).toThrow()
+  })
+
+  // Test error message
+  test('provides descriptive error message', () => {
+    try {
+      ensureNumeric('not-a-number')
+    } catch (error) {
+      expect(error.message).toContain('not-a-number')
+      expect(error.message).toContain('not a valid number')
+    }
+  })
+})
+
 describe('ensureString', () => {
   it('should return an empty string if the value is falsy', () => {
     expect(ensureString(null)).toBe('')
@@ -185,3 +313,68 @@ describe('ensureDateTime', () => {
     ).toBe(date)
   })
 })
+
+describe('QUERY_PARAM_TYPE_HANDLER_FUNCTIONS', () => {
+  describe('numeric handler', () => {
+    const numericHandler = QUERY_PARAM_TYPE_HANDLER_FUNCTIONS.numeric
+
+    it('should return null for empty input', () => {
+      expect(numericHandler('')).toBe(null)
+      expect(numericHandler(null)).toBe(null)
+      expect(numericHandler(undefined)).toBe(null)
+    })
+
+    it('should handle single numeric string', () => {
+      expect(numericHandler('5')).toBe(5)
+      expect(numericHandler('42')).toBe(42)
+      expect(numericHandler('1.5')).toBe(1.5)
+      expect(numericHandler('-1')).toBe(-1)
+    })
+
+    it('should handle comma-separated numeric strings', () => {
+      expect(numericHandler('1,2,3')).toEqual([1, 2, 3])
+      expect(numericHandler('42,123')).toEqual([42, 123])
+    })
+
+    it('should filter out invalid values from arrays', () => {
+      expect(numericHandler('1,2,')).toEqual([1, 2, null])
+      expect(numericHandler('1,2')).toEqual([1, 2])
+      expect(numericHandler(',1,2')).toEqual([null, 1, 2])
+      expect(numericHandler('1,,2')).toEqual([1, null, 2])
+    })
+
+    it('should throw error for invalid numeric values', () => {
+      expect(() => numericHandler('1,abc,3')).toThrow()
+    })
+  })
+
+  describe('text handler', () => {
+    const textHandler = QUERY_PARAM_TYPE_HANDLER_FUNCTIONS.text
+
+    it('should return null for empty input', () => {
+      expect(textHandler('')).toBe(null)
+      expect(textHandler(null)).toBe(null)
+      expect(textHandler(undefined)).toBe(null)
+    })
+
+    it('should handle single text value', () => {
+      expect(textHandler('hello')).toBe('hello')
+      expect(textHandler('test123')).toBe('test123')
+    })
+
+    it('should handle comma-separated text values', () => {
+      expect(textHandler('hello,world')).toEqual(['hello', 'world'])
+      expect(textHandler('a,b,c')).toEqual(['a', 'b', 'c'])
+    })
+
+    it('should filter out empty values from arrays', () => {
+      expect(textHandler('test,')).toEqual(['test', ''])
+      expect(textHandler('test,,value')).toEqual(['test', '', 'value'])
+    })
+
+    it('should handle numeric values as strings', () => {
+      expect(textHandler('123')).toBe('123')
+      expect(textHandler('test,123,abc')).toEqual(['test', '123', 'abc'])
+    })
+  })
+})