1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-18 03:13:47 +00:00

Merge branch '3408-fix--input-decimal-seperator' into 'develop'

Fix "number" validation for handling of decimal numbers with using comma or dot as decimal separator

Closes 

See merge request 
This commit is contained in:
Evren Ozkan 2025-04-07 08:33:33 +00:00
commit 8c2c0f5d8b
16 changed files with 353 additions and 75 deletions
backend
src/baserow
contrib/builder/elements
core/formula
tests/baserow
changelog/entries/unreleased/bug
web-frontend
modules
builder
components/elements
baseComponents
components
elementTypes.js
locales
core/utils
database
test/unit/core/utils

View file

@ -1,6 +1,7 @@
import abc
import uuid
from datetime import datetime
from decimal import InvalidOperation
from typing import (
Any,
Callable,
@ -101,6 +102,7 @@ from baserow.core.formula.validator import (
ensure_array,
ensure_boolean,
ensure_integer,
ensure_numeric,
ensure_string_or_integer,
)
from baserow.core.registry import Instance, T
@ -1464,7 +1466,7 @@ class InputTextElementType(InputElementType):
def is_valid(
self, element: InputTextElement, value: Any, dispatch_context: DispatchContext
) -> bool:
) -> Any:
"""
:param element: The element we're trying to use form data in.
:param value: The form data value, which may be invalid.
@ -1477,10 +1479,10 @@ class InputTextElementType(InputElementType):
elif element.validation_type == "integer":
try:
value = ensure_integer(value)
except ValidationError as exc:
return ensure_numeric(value)
except (ValueError, TypeError, InvalidOperation, ValidationError) as exc:
raise FormDataProviderChunkInvalidException(
f"{value} must be a valid integer."
f"{value} must be a valid number."
) from exc
elif element.validation_type == "email":

View file

@ -1,5 +1,7 @@
import json
import re
from datetime import date, datetime
from decimal import Decimal
from typing import Any, List, Optional, Union
from django.core.exceptions import ValidationError
@ -29,6 +31,50 @@ def ensure_boolean(value: Any) -> bool:
raise ValidationError("Value is not a valid boolean or convertible to a boolean.")
def ensure_numeric(
value: Any, allow_null: bool = False
) -> Optional[Union[int, float, Decimal]]:
"""
Ensures that the value is a number or can be converted to a numeric value.
:param value: The value to ensure as a number.
:param allow_null: Whether to allow null or empty values.
:return: The value as a number (int, float, or Decimal) if conversion is successful.
:raises ValidationError: If the value is not a valid number or convertible
to a number.
"""
if allow_null and (value is None or value == ""):
return None
# Handle numeric types directly
if isinstance(value, (int, float, Decimal)) and not isinstance(value, bool):
return value
# Handle string conversion
if isinstance(value, str):
# Check if the string matches a valid number pattern
if re.match(r"^([-+])?(\d+(\.\d+)?)$", value):
# Convert to int if it's a whole number, otherwise float
try:
num_value = float(value)
if num_value.is_integer():
return int(value)
return num_value
except ValueError:
# If float conversion fails, try Decimal as a fallback
try:
return Decimal(value)
except Exception as exc:
raise ValidationError(
f"Value '{value}' is not a valid number or convertible to a number."
) from exc
raise ValidationError(
f"Value '{value}' is not a valid number or convertible to a number."
)
def ensure_integer(value: Any, allow_empty: bool = False) -> Optional[int]:
"""
Ensures that the value is an integer or can be converted to an integer.

View file

@ -506,48 +506,43 @@ def test_choice_element_import_export_formula(data_fixture):
@pytest.mark.django_db
def test_input_text_element_is_valid(data_fixture):
validity_tests = [
{"required": True, "type": "integer", "value": "", "result": False},
{"required": True, "type": "integer", "value": 42, "result": 42},
{"required": True, "type": "integer", "value": "42", "result": 42},
{"required": True, "type": "integer", "value": "horse", "result": False},
{"required": False, "type": "integer", "value": "", "result": ""},
{
"required": True,
"type": "email",
"value": "foo@bar.com",
"result": "foo@bar.com",
},
{"required": True, "type": "email", "value": "foobar.com", "result": False},
{"required": False, "type": "email", "value": "", "result": ""},
{"required": True, "type": "any", "value": "", "result": False},
{"required": True, "type": "any", "value": 42, "result": 42},
{"required": True, "type": "any", "value": "42", "result": "42"},
{"required": True, "type": "any", "value": "horse", "result": "horse"},
{"required": False, "type": "any", "value": "", "result": ""},
]
for test in validity_tests:
if test["result"] is not False:
assert (
InputTextElementType().is_valid(
InputTextElement(
validation_type=test["type"], required=test["required"]
),
test["value"],
{},
)
== test["result"]
), repr(test["value"])
else:
with pytest.raises(FormDataProviderChunkInvalidException):
InputTextElementType().is_valid(
InputTextElement(
validation_type=test["type"], required=test["required"]
),
test["value"],
{},
)
@pytest.mark.parametrize(
"required,type,value,result",
[
(True, "integer", "", False),
(True, "integer", 42, 42),
(True, "integer", "4.2", 4.2),
(True, "integer", "4,2", False),
(True, "integer", "42", 42),
(True, "integer", "horse", False),
(False, "integer", "", ""),
(True, "email", "foo@bar.com", "foo@bar.com"),
(True, "email", "foobar.com", False),
(False, "email", "", ""),
(True, "any", "", False),
(True, "any", 42, 42),
(True, "any", "42", "42"),
(True, "any", "horse", "horse"),
(False, "any", "", ""),
],
)
def test_input_text_element_is_valid(data_fixture, required, type, value, result):
if result is not False:
assert (
InputTextElementType().is_valid(
InputTextElement(validation_type=type, required=required),
value,
{},
)
== result
), repr(f"{value} != {result}")
else:
with pytest.raises(FormDataProviderChunkInvalidException):
InputTextElementType().is_valid(
InputTextElement(validation_type=type, required=required),
value,
{},
)
@pytest.mark.django_db

View file

@ -1,10 +1,15 @@
from decimal import Decimal
from unittest.mock import patch
from django.core.exceptions import ValidationError
import pytest
from baserow.core.formula.validator import ensure_string, ensure_string_or_integer
from baserow.core.formula.validator import (
ensure_numeric,
ensure_string,
ensure_string_or_integer,
)
@pytest.mark.parametrize("value", [0, 1, 10, 100])
@ -191,3 +196,104 @@ def test_ensure_string_returns_str_by_default(value, expected):
result = ensure_string(value)
assert result == expected
@pytest.mark.parametrize(
"value,expected",
[
(0, 0),
(1, 1),
(10, 10),
(-5, -5),
(3.14, 3.14),
(-2.5, -2.5),
(Decimal("10.5"), Decimal("10.5")),
("0", 0),
("1", 1),
("10", 10),
("-5", -5),
("3.14", 3.14),
("-2.5", -2.5),
("10.5", 10.5),
],
)
def test_ensure_numeric_returns_correct_numeric_value(value, expected):
"""
Test the ensure_numeric() function.
Ensure that valid numeric values or
convertible values return the correct numeric type.
"""
result = ensure_numeric(value)
assert result == expected
assert type(result) is type(expected)
@pytest.mark.parametrize(
"value",
[
"abc",
"1a",
"a1",
"1.2.3",
"1,000",
True,
False,
[],
{},
object(),
],
)
def test_ensure_numeric_raises_error_for_invalid_values(value):
"""
Test the ensure_numeric() function.
Ensure a ValidationError is raised
if the value cannot be converted to a numeric type.
"""
with pytest.raises(ValidationError) as e:
ensure_numeric(value)
assert f"Value '{value}' is not a valid number or convertible to a number." in str(
e.value
)
@pytest.mark.parametrize(
"value,allow_null,expected",
[
(None, True, None),
("", True, None),
(None, False, None), # This will raise an error
("", False, None), # This will raise an error
],
)
def test_ensure_numeric_handles_null_values(value, allow_null, expected):
"""
Test the ensure_numeric() function.
Ensure that None or empty string values are handled correctly
based on allow_null parameter.
"""
if not allow_null:
with pytest.raises(ValidationError):
ensure_numeric(value, allow_null=allow_null)
else:
result = ensure_numeric(value, allow_null=allow_null)
assert result is expected
def test_ensure_numeric_with_very_large_numbers():
"""
Test the ensure_numeric() function with very large numbers.
Ensure that very large numbers are handled correctly.
"""
large_number = "9" * 100
result = ensure_numeric(large_number)
assert isinstance(result, (int, float, Decimal))
assert str(result) == large_number

View file

@ -0,0 +1,8 @@
{
"type": "bug",
"message": "Fix \"number\" validation for handling of decimal numbers with using comma or dot as decimal separator",
"domain": "builder",
"issue_number": 3408,
"bullet_points": [],
"created_at": "2025-03-27"
}

View file

@ -4,11 +4,11 @@
ref="textarea"
class="ab-input"
style="resize: none"
:value="value"
:value="fromValue(value)"
:placeholder="placeholder"
:rows="rows"
@blur="$emit('blur', $event)"
@input="$emit('input', $event.target.value)"
@input="$emit('input', toValue($event.target.value))"
@focus="$emit('focus', $event)"
@click="$emit('click', $event)"
></textarea>
@ -17,10 +17,10 @@
ref="input"
:type="type"
class="ab-input"
:value="value"
:value="fromValue(value)"
:placeholder="placeholder"
@blur="$emit('blur', $event)"
@input="$emit('input', $event.target.value)"
@input="$emit('input', toValue($event.target.value))"
@focus="$emit('focus', $event)"
@click="$emit('click', $event)"
/>
@ -38,7 +38,7 @@ export default {
* @type {string} - The value of the input.
*/
value: {
type: String,
type: [String, Number],
required: false,
default: '',
},
@ -50,6 +50,22 @@ export default {
required: false,
default: null,
},
/**
* @type {Function} - The function to process user input before storing it.
*/
toValue: {
type: Function,
required: false,
default: (value) => value,
},
/**
* @type {Function} - The function to process/convert the value to a string.
*/
fromValue: {
type: Function,
required: false,
default: (value) => value,
},
/**
* @type {boolean} - Whether the input is multiline.
*/

View file

@ -7,7 +7,7 @@
:style="getStyleOverride('input')"
>
<ABInput
v-model="inputValue"
v-model="computedValue"
:placeholder="resolvedPlaceholder"
:multiline="element.is_multiline"
:rows="element.rows"
@ -19,7 +19,11 @@
<script>
import formElement from '@baserow/modules/builder/mixins/formElement'
import { ensureString } from '@baserow/modules/core/utils/validator'
import {
ensureNumeric,
ensureString,
} from '@baserow/modules/core/utils/validator'
import { parseLocalizedNumber } from '@baserow/modules/core/utils/string'
export default {
name: 'InputTextElement',
@ -38,9 +42,37 @@ export default {
required: true,
},
},
data() {
return {
internalValue: '',
}
},
computed: {
computedValue: {
get() {
return this.internalValue
},
set(newValue) {
this.inputValue = this.fromInternalValue(newValue)
this.internalValue = newValue
},
},
localeLanguage() {
return this.$i18n.locale
},
resolvedDefaultValue() {
return ensureString(this.resolveFormula(this.element.default_value))
try {
const value = this.resolveFormula(this.element.default_value)
return this.isNumericField
? ensureNumeric(value, { allowNull: true })
: ensureString(value)
} catch {
return null
}
},
isNumericField() {
return this.element.validation_type === 'integer'
},
resolvedLabel() {
return ensureString(this.resolveFormula(this.element.label))
@ -53,11 +85,46 @@ export default {
resolvedDefaultValue: {
handler(value) {
this.inputValue = value
this.internalValue = this.toInternalValue(value)
},
immediate: true,
},
},
methods: {
toInternalValue(value) {
if (this.isNumericField) {
if (value) {
return new Intl.NumberFormat(this.localeLanguage, {
useGrouping: false,
}).format(value)
} else {
return ''
}
}
return ensureString(value)
},
fromInternalValue(value) {
if (this.isNumericField) {
if (value) {
try {
return ensureNumeric(
parseLocalizedNumber(value, this.localeLanguage),
{
allowNull: true,
}
)
} catch {
return value
}
} else {
return null
}
}
return value
},
getErrorMessage() {
switch (this.element.validation_type) {
case 'integer':

View file

@ -14,6 +14,7 @@ import TableElementForm from '@baserow/modules/builder/components/elements/compo
import {
ensureArray,
ensureBoolean,
ensureNumeric,
ensureInteger,
ensurePositiveInteger,
ensureString,
@ -1140,7 +1141,7 @@ export class InputTextElementType extends FormElementType {
}
formDataType(element) {
return 'string'
return element.validation_type === 'integer' ? 'number' : 'string'
}
getDisplayName(element, applicationContext) {
@ -1158,12 +1159,16 @@ export class InputTextElementType extends FormElementType {
getInitialFormDataValue(element, applicationContext) {
try {
return this.resolveFormula(element.default_value, {
const value = this.resolveFormula(element.default_value, {
element,
...applicationContext,
})
return element.validation_type === 'integer'
? ensureNumeric(value, { allowNull: true })
: ensureString(value)
} catch {
return ''
return null
}
}
}

View file

@ -266,7 +266,7 @@
"validationTypeAnyLabel": "Any",
"validationTypeAnyDescription": "Allow any value to be set in this input.",
"validationTypeIntegerLabel": "Number",
"validationTypeIntegerDescription": "Enforce a number value in this input.",
"validationTypeIntegerDescription": "Enforce a numeric value in this input (accepts integers and decimals).",
"validationTypeEmailLabel": "Email",
"validationTypeEmailDescription": "Enforce an email address value in this input.",
"inputType": "Input type",

View file

@ -137,10 +137,29 @@ export const escapeRegExp = (string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export const isNumeric = (value) => {
export const isInteger = (value) => {
return /^-?\d+$/.test(value)
}
export const isNumeric = (value) => {
return /^-?\d+([.]\d+)?$/.test(value)
}
export const parseLocalizedNumber = (str, locale) => {
const parts = new Intl.NumberFormat(locale).formatToParts(12345.6)
let group = parts.find((p) => p.type === 'group')?.value || ''
let decimal = parts.find((p) => p.type === 'decimal')?.value || '.'
// Escape special characters for regex
group = group.replace(/[\u202F\u00A0\s]/g, '\\s') // match all common spaces
decimal = decimal === '.' ? '\\.' : decimal
group = group === '.' ? '\\.' : group
const groupRegex = new RegExp(group, 'g')
// Remove group separator and replace decimal with "."
return str.replace(groupRegex, '').replace(decimal, '.')
}
/**
* Allow to find the next unused name excluding a list of names.
* This is the frontend equivalent of backend ".find_unused_name()" method.

View file

@ -12,7 +12,7 @@ import moment from '@baserow/modules/core/moment'
* @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 === '')) {
if (allowNull && (value === null || value === '' || value === undefined)) {
return null
}
if (Number.isFinite(value)) {

View file

@ -40,7 +40,7 @@
</template>
<script>
import { isNumeric } from '@baserow/modules/core/utils/string'
import { isInteger } from '@baserow/modules/core/utils/string'
import { getPersistentFieldOptionsKey } from '@baserow/modules/database/utils/field'
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
import SelectRowModal from '@baserow/modules/database/components/row/SelectRowModal'
@ -61,7 +61,7 @@ export default {
},
computed: {
valid() {
return isNumeric(this.filter.value)
return isInteger(this.filter.value)
},
isDropdown() {
return this.readOnly && this.view && this.isPublicView

View file

@ -10,7 +10,7 @@ import {
import {
collatedStringCompare,
getFilenameFromUrl,
isNumeric,
isInteger,
isSimplePhoneNumber,
isValidEmail,
isValidURL,
@ -2842,7 +2842,7 @@ export class DurationFieldType extends FieldType {
}
prepareValueForPaste(field, clipboardData, richClipboardData) {
if (richClipboardData && isNumeric(richClipboardData)) {
if (richClipboardData && isInteger(richClipboardData)) {
return richClipboardData
}
return this.parseInputValue(field, clipboardData)
@ -3383,7 +3383,7 @@ export class SingleSelectFieldType extends SelectOptionBaseFieldType {
}
_findOptionWithMatchingId(field, rawTextValue) {
if (isNumeric(rawTextValue)) {
if (isInteger(rawTextValue)) {
const pastedOptionId = parseInt(rawTextValue, 10)
return field.select_options.find((option) => option.id === pastedOptionId)
}

View file

@ -22,7 +22,7 @@ import {
DATE_FILTER_OPERATOR_BOUNDS,
DateFilterOperators,
} from '@baserow/modules/database/utils/date'
import { isNumeric } from '@baserow/modules/core/utils/string'
import { isInteger } from '@baserow/modules/core/utils/string'
import ViewFilterTypeFileTypeDropdown from '@baserow/modules/database/components/view/ViewFilterTypeFileTypeDropdown'
import ViewFilterTypeCollaborators from '@baserow/modules/database/components/view/ViewFilterTypeCollaborators'
import {
@ -2446,7 +2446,7 @@ export class MultipleCollaboratorsHasFilterType extends ViewFilterType {
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
if (!isInteger(filterValue)) {
return true
}
@ -2481,7 +2481,7 @@ export class MultipleCollaboratorsHasNotFilterType extends ViewFilterType {
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
if (!isInteger(filterValue)) {
return true
}
@ -2517,7 +2517,7 @@ export class UserIsFilterType extends ViewFilterType {
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
if (!isInteger(filterValue)) {
return true
}
@ -2553,7 +2553,7 @@ export class UserIsNotFilterType extends ViewFilterType {
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
if (!isInteger(filterValue)) {
return true
}
@ -2624,7 +2624,7 @@ export class LinkRowHasFilterType extends ViewFilterType {
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
if (!isInteger(filterValue)) {
return true
}
@ -2656,7 +2656,7 @@ export class LinkRowHasNotFilterType extends ViewFilterType {
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
if (!isInteger(filterValue)) {
return true
}

View file

@ -6,6 +6,7 @@ import {
isValidEmail,
isSecureURL,
isNumeric,
isInteger,
isSubstringOfStrings,
} from '@baserow/modules/core/utils/string'
@ -131,7 +132,8 @@ describe('test string utils', () => {
test('test isNumeric', () => {
expect(isNumeric('a')).toBe(false)
expect(isNumeric('1.2')).toBe(false)
expect(isNumeric('1.2')).toBe(true)
expect(isNumeric('1,2')).toBe(false)
expect(isNumeric('')).toBe(false)
expect(isNumeric('null')).toBe(false)
expect(isNumeric('12px')).toBe(false)
@ -140,6 +142,17 @@ describe('test string utils', () => {
expect(isNumeric('-100')).toBe(true)
})
test('test isInteger', () => {
expect(isInteger('a')).toBe(false)
expect(isInteger('1.2')).toBe(false)
expect(isInteger('1,2')).toBe(false)
expect(isInteger('')).toBe(false)
expect(isInteger('null')).toBe(false)
expect(isInteger('12px')).toBe(false)
expect(isInteger('1')).toBe(true)
expect(isInteger('9999')).toBe(true)
expect(isInteger('-100')).toBe(true)
})
test('test isSubstringOfStrings', () => {
expect(isSubstringOfStrings(['hello'], 'hell')).toBe(true)
expect(isSubstringOfStrings(['test'], 'hell')).toBe(false)

View file

@ -111,7 +111,9 @@ describe('ensureNumeric', () => {
// Test null handling
test('handles null values based on allowNull option', () => {
expect(() => ensureNumeric(null)).toThrow()
expect(() => ensureNumeric(undefined)).toThrow()
expect(ensureNumeric(null, { allowNull: true })).toBeNull()
expect(ensureNumeric(undefined, { allowNull: true })).toBeNull()
})
// Test empty string handling
@ -143,7 +145,6 @@ describe('ensureNumeric', () => {
test('throws error for undefined', () => {
expect(() => ensureNumeric(undefined)).toThrow()
expect(() => ensureNumeric(undefined, { allowNull: true })).toThrow()
})
// Test error message