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']) + }) + }) +})