mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-04 13:15:24 +00:00
Use a simpler regex to validate phone number field, add test covering validation and database conversion for phone number field, enable standard filters and sorts.
This commit is contained in:
parent
782ba27087
commit
afa640701e
26 changed files with 1192 additions and 66 deletions
backend
src/baserow/contrib/database
tests
docs/decisions
web-frontend/modules
core/utils
database
|
@ -47,7 +47,7 @@ class DatabaseConfig(AppConfig):
|
|||
from .fields.field_types import (
|
||||
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType,
|
||||
BooleanFieldType, DateFieldType, LinkRowFieldType, EmailFieldType,
|
||||
FileFieldType, SingleSelectFieldType
|
||||
FileFieldType, SingleSelectFieldType, PhoneNumberFieldType
|
||||
)
|
||||
field_type_registry.register(TextFieldType())
|
||||
field_type_registry.register(LongTextFieldType())
|
||||
|
@ -59,6 +59,7 @@ class DatabaseConfig(AppConfig):
|
|||
field_type_registry.register(LinkRowFieldType())
|
||||
field_type_registry.register(FileFieldType())
|
||||
field_type_registry.register(SingleSelectFieldType())
|
||||
field_type_registry.register(PhoneNumberFieldType())
|
||||
|
||||
from .fields.field_converters import LinkRowFieldConverter, FileFieldConverter
|
||||
field_converter_registry.register(LinkRowFieldConverter())
|
||||
|
|
|
@ -10,9 +10,9 @@ class PostgresqlLenientDatabaseSchemaEditor:
|
|||
format. If the casting still fails the value will be set to null.
|
||||
"""
|
||||
|
||||
sql_alter_column_type = "ALTER COLUMN %(column)s TYPE %(type)s " \
|
||||
"USING pg_temp.try_cast(%(column)s::text)"
|
||||
sql_drop_try_cast = "DROP FUNCTION IF EXISTS pg_temp.try_cast(text, int)"
|
||||
sql_alter_column_type = 'ALTER COLUMN %(column)s TYPE %(type)s ' \
|
||||
'USING pg_temp.try_cast(%(column)s::text)'
|
||||
sql_drop_try_cast = 'DROP FUNCTION IF EXISTS pg_temp.try_cast(text, int)'
|
||||
sql_create_try_cast = """
|
||||
create or replace function pg_temp.try_cast(
|
||||
p_in text,
|
||||
|
@ -35,16 +35,20 @@ class PostgresqlLenientDatabaseSchemaEditor:
|
|||
"""
|
||||
|
||||
def __init__(self, *args, alter_column_prepare_old_value='',
|
||||
alter_column_prepare_new_value=''):
|
||||
alter_column_prepare_new_value='',
|
||||
force_alter_column=False):
|
||||
self.alter_column_prepare_old_value = alter_column_prepare_old_value
|
||||
self.alter_column_prepare_new_value = alter_column_prepare_new_value
|
||||
self.force_alter_column = force_alter_column
|
||||
super().__init__(*args)
|
||||
|
||||
def _alter_field(self, model, old_field, new_field, old_type, new_type,
|
||||
old_db_params, new_db_params, strict=False):
|
||||
if self.force_alter_column:
|
||||
old_type = f'{old_type}_forced'
|
||||
|
||||
if old_type != new_type:
|
||||
variables = {}
|
||||
|
||||
if isinstance(self.alter_column_prepare_old_value, tuple):
|
||||
alter_column_prepare_old_value, v = self.alter_column_prepare_old_value
|
||||
variables = {**variables, **v}
|
||||
|
@ -57,12 +61,13 @@ class PostgresqlLenientDatabaseSchemaEditor:
|
|||
else:
|
||||
alter_column_prepare_new_value = self.alter_column_prepare_new_value
|
||||
|
||||
quoted_column_name = self.quote_name(new_field.column)
|
||||
self.execute(self.sql_drop_try_cast)
|
||||
self.execute(self.sql_create_try_cast % {
|
||||
"column": self.quote_name(new_field.column),
|
||||
"type": new_type,
|
||||
"alter_column_prepare_old_value": alter_column_prepare_old_value,
|
||||
"alter_column_prepare_new_value": alter_column_prepare_new_value
|
||||
'column': quoted_column_name,
|
||||
'type': new_type,
|
||||
'alter_column_prepare_old_value': alter_column_prepare_old_value,
|
||||
'alter_column_prepare_new_value': alter_column_prepare_new_value
|
||||
}, variables)
|
||||
|
||||
return super()._alter_field(model, old_field, new_field, old_type, new_type,
|
||||
|
@ -71,7 +76,8 @@ class PostgresqlLenientDatabaseSchemaEditor:
|
|||
|
||||
@contextlib.contextmanager
|
||||
def lenient_schema_editor(connection, alter_column_prepare_old_value=None,
|
||||
alter_column_prepare_new_value=None):
|
||||
alter_column_prepare_new_value=None,
|
||||
force_alter_column=False):
|
||||
"""
|
||||
A contextual function that yields a modified version of the connection's schema
|
||||
editor. This temporary version is more lenient then the regular editor. Normally
|
||||
|
@ -89,6 +95,9 @@ def lenient_schema_editor(connection, alter_column_prepare_old_value=None,
|
|||
:param alter_column_prepare_new_value: Optionally a query statement converting the
|
||||
`p_in` text value to the new type.
|
||||
:type alter_column_prepare_new_value: None or str
|
||||
:param force_alter_column: When true forces the schema editor to run an alter
|
||||
column statement using the previous two alter_column_prepare parameters.
|
||||
:type force_alter_column: bool
|
||||
:raises ValueError: When the provided connection is not supported. For now only
|
||||
`postgresql` is supported.
|
||||
"""
|
||||
|
@ -109,7 +118,9 @@ def lenient_schema_editor(connection, alter_column_prepare_old_value=None,
|
|||
|
||||
connection.SchemaEditorClass = schema_editor_class
|
||||
|
||||
kwargs = {}
|
||||
kwargs = {
|
||||
'force_alter_column': force_alter_column
|
||||
}
|
||||
|
||||
if alter_column_prepare_old_value:
|
||||
kwargs['alter_column_prepare_old_value'] = alter_column_prepare_old_value
|
||||
|
|
|
@ -6,7 +6,7 @@ from dateutil import parser
|
|||
from dateutil.parser import ParserError
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator, EmailValidator
|
||||
from django.core.validators import URLValidator, EmailValidator, RegexValidator
|
||||
from django.db import models
|
||||
from django.db.models import Case, When, Q, F, Func, Value, CharField
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
@ -35,7 +35,7 @@ from .models import (
|
|||
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, URLField,
|
||||
NumberField, BooleanField, DateField,
|
||||
LinkRowField, EmailField, FileField,
|
||||
SingleSelectField, SelectOption
|
||||
SingleSelectField, SelectOption, PhoneNumberField
|
||||
)
|
||||
from .registries import FieldType, field_type_registry
|
||||
|
||||
|
@ -196,25 +196,8 @@ class NumberFieldType(FieldType):
|
|||
return super().get_alter_column_prepare_new_value(connection, from_field,
|
||||
to_field)
|
||||
|
||||
def after_update(self, from_field, to_field, from_model, to_model, user, connection,
|
||||
altered_column, before):
|
||||
"""
|
||||
The allowing of negative values isn't stored in the database field type. If
|
||||
the type hasn't changed, but the allowing of negative values has it means that
|
||||
the column data hasn't been converted to positive values yet. We need to do
|
||||
this here. All the negatives values are set to 0.
|
||||
"""
|
||||
|
||||
if (
|
||||
not altered_column
|
||||
and not to_field.number_negative
|
||||
and from_field.number_negative
|
||||
):
|
||||
to_model.objects.filter(**{
|
||||
f'field_{to_field.id}__lt': 0
|
||||
}).update(**{
|
||||
f'field_{to_field.id}': 0
|
||||
})
|
||||
def force_same_type_alter_column(self, from_field, to_field):
|
||||
return not to_field.number_negative and from_field.number_negative
|
||||
|
||||
def contains_query(self, *args):
|
||||
return contains_filter(*args)
|
||||
|
@ -1035,3 +1018,85 @@ class SingleSelectFieldType(FieldType):
|
|||
annotation={f"select_option_value_{field_name}": query},
|
||||
q={f'select_option_value_{field_name}__icontains': value}
|
||||
)
|
||||
|
||||
|
||||
class PhoneNumberFieldType(FieldType):
|
||||
"""
|
||||
A simple wrapper around a TextField which ensures any entered data is a
|
||||
simple phone number.
|
||||
|
||||
See `docs/decisions/001-phone-number-field-validation.md` for context
|
||||
as to why the phone number validation was implemented using a simple regex.
|
||||
"""
|
||||
|
||||
type = 'phone_number'
|
||||
model_class = PhoneNumberField
|
||||
|
||||
MAX_PHONE_NUMBER_LENGTH = 100
|
||||
"""
|
||||
According to the E.164 (https://en.wikipedia.org/wiki/E.164) standard for
|
||||
international numbers the max length of an E.164 number without formatting is 15
|
||||
characters. However we allow users to store formatting characters, spaces and
|
||||
expect them to be entering numbers not in the E.164 standard but instead a
|
||||
wide range of local standards which might support longer numbers.
|
||||
This is why we have picked a very generous 100 character length to support heavily
|
||||
formatted local numbers.
|
||||
"""
|
||||
|
||||
PHONE_NUMBER_REGEX = rf'^[0-9NnXx,+._*()#=;/ -]{{1,{MAX_PHONE_NUMBER_LENGTH}}}$'
|
||||
"""
|
||||
Allow common punctuation used in phone numbers and spaces to allow formatting,
|
||||
but otherwise don't allow text as the phone number should work as a link on mobile
|
||||
devices.
|
||||
Duplicated in the frontend code at, please keep in sync:
|
||||
web-frontend/modules/core/utils/string.js#isSimplePhoneNumber
|
||||
"""
|
||||
|
||||
simple_phone_number_validator = RegexValidator(
|
||||
regex=PHONE_NUMBER_REGEX)
|
||||
|
||||
def prepare_value_for_db(self, instance, value):
|
||||
if value == '' or value is None:
|
||||
return ''
|
||||
self.simple_phone_number_validator(value)
|
||||
|
||||
return value
|
||||
|
||||
def get_serializer_field(self, instance, **kwargs):
|
||||
return serializers.CharField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
allow_blank=True,
|
||||
validators=[self.simple_phone_number_validator],
|
||||
max_length=self.MAX_PHONE_NUMBER_LENGTH,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
return models.CharField(
|
||||
default='',
|
||||
blank=True,
|
||||
null=True,
|
||||
max_length=self.MAX_PHONE_NUMBER_LENGTH,
|
||||
validators=[
|
||||
self.simple_phone_number_validator],
|
||||
**kwargs)
|
||||
|
||||
def random_value(self, instance, fake, cache):
|
||||
return fake.phone_number()
|
||||
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
if connection.vendor == 'postgresql':
|
||||
return f'''p_in = (
|
||||
case
|
||||
when p_in::text ~* '{self.PHONE_NUMBER_REGEX}'
|
||||
then p_in::text
|
||||
else ''
|
||||
end
|
||||
);'''
|
||||
|
||||
return super().get_alter_column_prepare_new_value(connection, from_field,
|
||||
to_field)
|
||||
|
||||
def contains_query(self, *args):
|
||||
return contains_filter(*args)
|
||||
|
|
|
@ -1,23 +1,21 @@
|
|||
import logging
|
||||
from copy import deepcopy
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connections
|
||||
from django.db.utils import ProgrammingError, DataError
|
||||
from django.conf import settings
|
||||
|
||||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
from baserow.contrib.database.db.schema import lenient_schema_editor
|
||||
from baserow.contrib.database.views.handler import ViewHandler
|
||||
|
||||
from baserow.core.utils import extract_allowed, set_allowed_attrs
|
||||
from .exceptions import (
|
||||
PrimaryFieldAlreadyExists, CannotDeletePrimaryField, CannotChangeFieldType,
|
||||
FieldDoesNotExist, IncompatiblePrimaryFieldTypeError
|
||||
)
|
||||
from .registries import field_type_registry, field_converter_registry
|
||||
from .models import Field, SelectOption
|
||||
from .registries import field_type_registry, field_converter_registry
|
||||
from .signals import field_created, field_updated, field_deleted
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
@ -159,7 +157,8 @@ class FieldHandler:
|
|||
# If the provided field type does not match with the current one we need to
|
||||
# migrate the field to the new type. Because the type has changed we also need
|
||||
# to remove all view filters.
|
||||
if new_type_name and field_type.type != new_type_name:
|
||||
baserow_field_type_changed = new_type_name and field_type.type != new_type_name
|
||||
if baserow_field_type_changed:
|
||||
field_type = field_type_registry.get(new_type_name)
|
||||
|
||||
if field.primary and not field_type.can_be_primary_field:
|
||||
|
@ -217,6 +216,17 @@ class FieldHandler:
|
|||
connection
|
||||
)
|
||||
else:
|
||||
if baserow_field_type_changed:
|
||||
# If the baserow type has changed we always want to force run any alter
|
||||
# column SQL as otherwise it might not run if the two baserow fields
|
||||
# share the same underlying database column type.
|
||||
force_alter_column = True
|
||||
else:
|
||||
force_alter_column = field_type.force_same_type_alter_column(
|
||||
old_field,
|
||||
field
|
||||
)
|
||||
|
||||
# If no field converter is found we are going to alter the field using the
|
||||
# the lenient schema editor.
|
||||
with lenient_schema_editor(
|
||||
|
@ -225,7 +235,8 @@ class FieldHandler:
|
|||
connection, old_field, field),
|
||||
field_type.get_alter_column_prepare_new_value(
|
||||
connection, old_field, field
|
||||
)
|
||||
),
|
||||
force_alter_column
|
||||
) as schema_editor:
|
||||
try:
|
||||
schema_editor.alter_field(from_model, from_model_field,
|
||||
|
|
|
@ -304,3 +304,7 @@ class FileField(Field):
|
|||
|
||||
class SingleSelectField(Field):
|
||||
pass
|
||||
|
||||
|
||||
class PhoneNumberField(Field):
|
||||
pass
|
||||
|
|
|
@ -215,6 +215,9 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
|
|||
"""
|
||||
Can return an SQL statement to convert the `p_in` variable to a readable text
|
||||
format for the new field.
|
||||
This SQL will not be run when converting between two fields of the same
|
||||
baserow type which share the same underlying database column type.
|
||||
If you require this then implement force_same_type_alter_column.
|
||||
|
||||
Example: return "p_in = lower(p_in);"
|
||||
|
||||
|
@ -236,8 +239,11 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
|
|||
"""
|
||||
Can return a SQL statement to convert the `p_in` variable from text to a
|
||||
desired format for the new field.
|
||||
This SQL will not be run when converting between two fields of the same
|
||||
baserow type which share the same underlying database column type.
|
||||
If you require this then implement force_same_type_alter_column.
|
||||
|
||||
Example when a string is converted to a number, to statement could be:
|
||||
Example: when a string is converted to a number, to statement could be:
|
||||
`REGEXP_REPLACE(p_in, '[^0-9]', '', 'g')` which would remove all non numeric
|
||||
characters. The p_in variable is the old value as a string.
|
||||
|
||||
|
@ -410,6 +416,30 @@ class FieldType(MapAPIExceptionsInstanceMixin, APIUrlsInstanceMixin,
|
|||
|
||||
return None
|
||||
|
||||
def force_same_type_alter_column(self, from_field, to_field):
|
||||
"""
|
||||
Defines whether the sql provided by the get_alter_column_prepare_{old,new}_value
|
||||
hooks should be forced to run when converting between two fields of this field
|
||||
type which have the same database column type.
|
||||
You only need to implement this when when you have validation and/or data
|
||||
manipulation running as part of your alter_column_prepare SQL which must be
|
||||
run even when from_field and to_field are the same Baserow field type and sql
|
||||
column type. If your field has the same baserow type but will convert into
|
||||
different sql column types then the alter sql will be run automatically and you
|
||||
do not need to use this override.
|
||||
|
||||
:param from_field: The old field instance. It is not recommended to call the
|
||||
save function as this will undo part of the changes that have been made.
|
||||
This is just for comparing values.
|
||||
:type from_field: Field
|
||||
:param to_field: The updated field instance.
|
||||
:type: to_field: Field
|
||||
:return: Whether the alter column sql should be forced to run.
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class FieldTypeRegistry(APIUrlsRegistryMixin, CustomFieldsRegistryMixin,
|
||||
ModelRegistryMixin, Registry):
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 2.2.11 on 2021-03-15 09:28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('database', '0028_fix_negative_date'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PhoneNumberField',
|
||||
fields=[
|
||||
('field_ptr', models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True, primary_key=True,
|
||||
serialize=False,
|
||||
to='database.Field'
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
bases=('database.field',),
|
||||
),
|
||||
]
|
|
@ -4,7 +4,7 @@ from .table.models import Table
|
|||
from .views.models import View, GridView, GridViewFieldOptions, ViewFilter
|
||||
from .fields.models import (
|
||||
Field, TextField, NumberField, LongTextField, BooleanField, DateField, LinkRowField,
|
||||
URLField, EmailField
|
||||
URLField, EmailField, PhoneNumberField
|
||||
)
|
||||
from .tokens.models import Token, TokenPermission
|
||||
|
||||
|
@ -13,7 +13,7 @@ __all__ = [
|
|||
'Table',
|
||||
'View', 'GridView', 'GridViewFieldOptions', 'ViewFilter',
|
||||
'Field', 'TextField', 'NumberField', 'LongTextField', 'BooleanField', 'DateField',
|
||||
'LinkRowField', 'URLField', 'EmailField',
|
||||
'LinkRowField', 'URLField', 'EmailField', 'PhoneNumberField',
|
||||
'Token', 'TokenPermission'
|
||||
]
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ from pytz import timezone
|
|||
from baserow.contrib.database.fields.field_types import (
|
||||
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, DateFieldType,
|
||||
LinkRowFieldType, BooleanFieldType, EmailFieldType, FileFieldType,
|
||||
SingleSelectFieldType
|
||||
SingleSelectFieldType, PhoneNumberFieldType
|
||||
)
|
||||
from .registries import ViewFilterType
|
||||
from baserow.contrib.database.fields.field_filters import contains_filter, \
|
||||
|
@ -38,7 +38,8 @@ class EqualViewFilterType(ViewFilterType):
|
|||
URLFieldType.type,
|
||||
NumberFieldType.type,
|
||||
BooleanFieldType.type,
|
||||
EmailFieldType.type
|
||||
EmailFieldType.type,
|
||||
PhoneNumberFieldType.type
|
||||
]
|
||||
|
||||
def get_filter(self, field_name, value, model_field, field):
|
||||
|
@ -89,7 +90,8 @@ class ContainsViewFilterType(ViewFilterType):
|
|||
TextFieldType.type,
|
||||
LongTextFieldType.type,
|
||||
URLFieldType.type,
|
||||
EmailFieldType.type
|
||||
EmailFieldType.type,
|
||||
PhoneNumberFieldType.type
|
||||
]
|
||||
|
||||
def get_filter(self, *args):
|
||||
|
@ -285,7 +287,8 @@ class EmptyViewFilterType(ViewFilterType):
|
|||
LinkRowFieldType.type,
|
||||
EmailFieldType.type,
|
||||
FileFieldType.type,
|
||||
SingleSelectFieldType.type
|
||||
SingleSelectFieldType.type,
|
||||
PhoneNumberFieldType.type
|
||||
]
|
||||
|
||||
def get_filter(self, field_name, value, model_field, field):
|
||||
|
|
|
@ -10,7 +10,8 @@ from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD
|
|||
from django.shortcuts import reverse
|
||||
|
||||
from baserow.contrib.database.fields.models import (
|
||||
LongTextField, URLField, DateField, EmailField, FileField, NumberField
|
||||
LongTextField, URLField, DateField, EmailField, FileField, NumberField,
|
||||
PhoneNumberField
|
||||
)
|
||||
|
||||
|
||||
|
@ -842,3 +843,96 @@ def test_number_field_type(api_client, data_fixture):
|
|||
response_json['detail'][f'field_{positive_int_field_id}'][0]['code'] ==
|
||||
'max_digits'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_phone_number_field_type(api_client, data_fixture):
|
||||
user, token = data_fixture.create_user_and_token(
|
||||
email='test@test.nl', password='password', first_name='Test1')
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:fields:list', kwargs={'table_id': table.id}),
|
||||
{'name': 'phone', 'type': 'phone_number'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json['type'] == 'phone_number'
|
||||
assert PhoneNumberField.objects.all().count() == 1
|
||||
field_id = response_json['id']
|
||||
|
||||
response = api_client.patch(
|
||||
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
|
||||
{'name': 'Phone'},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
assert response.status_code == HTTP_200_OK
|
||||
|
||||
expected_phone_number = '+44761198672'
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
|
||||
{
|
||||
f'field_{field_id}': expected_phone_number
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json[f'field_{field_id}'] == expected_phone_number
|
||||
|
||||
model = table.get_model(attribute_names=True)
|
||||
row = model.objects.all().last()
|
||||
assert row.phone == expected_phone_number
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
|
||||
{
|
||||
f'field_{field_id}': ''
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json[f'field_{field_id}'] == ''
|
||||
|
||||
row = model.objects.all().last()
|
||||
assert row.phone == ''
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
|
||||
{
|
||||
f'field_{field_id}': None
|
||||
},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json[f'field_{field_id}'] == ''
|
||||
|
||||
row = model.objects.all().last()
|
||||
assert row.phone == ''
|
||||
|
||||
response = api_client.post(
|
||||
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
|
||||
{},
|
||||
format='json',
|
||||
HTTP_AUTHORIZATION=f'JWT {token}'
|
||||
)
|
||||
response_json = response.json()
|
||||
assert response.status_code == HTTP_200_OK
|
||||
assert response_json[f'field_{field_id}'] == ''
|
||||
|
||||
row = model.objects.all().last()
|
||||
assert row.phone == ''
|
||||
|
||||
email = reverse('api:database:fields:item', kwargs={'field_id': field_id})
|
||||
response = api_client.delete(email, HTTP_AUTHORIZATION=f'JWT {token}')
|
||||
assert response.status_code == HTTP_204_NO_CONTENT
|
||||
assert PhoneNumberField.objects.all().count() == 0
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import pytest
|
||||
|
||||
from django.db import connection
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
from django.db.backends.dummy.base import DatabaseWrapper as DummyDatabaseWrapper
|
||||
|
@ -26,6 +25,7 @@ def test_lenient_schema_editor():
|
|||
assert isinstance(schema_editor, BaseDatabaseSchemaEditor)
|
||||
assert schema_editor.alter_column_prepare_old_value == ''
|
||||
assert schema_editor.alter_column_prepare_new_value == ''
|
||||
assert not schema_editor.force_alter_column
|
||||
assert connection.SchemaEditorClass != PostgresqlDatabaseSchemaEditor
|
||||
|
||||
assert connection.SchemaEditorClass == PostgresqlDatabaseSchemaEditor
|
||||
|
@ -33,7 +33,8 @@ def test_lenient_schema_editor():
|
|||
with lenient_schema_editor(
|
||||
connection,
|
||||
"p_in = REGEXP_REPLACE(p_in, '', 'test', 'g');",
|
||||
"p_in = REGEXP_REPLACE(p_in, 'test', '', 'g');"
|
||||
"p_in = REGEXP_REPLACE(p_in, 'test', '', 'g');",
|
||||
True
|
||||
) as schema_editor:
|
||||
assert schema_editor.alter_column_prepare_old_value == (
|
||||
"p_in = REGEXP_REPLACE(p_in, '', 'test', 'g');"
|
||||
|
@ -41,3 +42,4 @@ def test_lenient_schema_editor():
|
|||
assert schema_editor.alter_column_prepare_new_value == (
|
||||
"p_in = REGEXP_REPLACE(p_in, 'test', '', 'g');"
|
||||
)
|
||||
assert schema_editor.force_alter_column
|
||||
|
|
|
@ -1,18 +1,126 @@
|
|||
import pytest
|
||||
import itertools
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
from baserow.core.exceptions import UserNotInGroupError
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
from baserow.contrib.database.fields.models import (
|
||||
Field, TextField, NumberField, BooleanField, SelectOption
|
||||
)
|
||||
from baserow.contrib.database.fields.field_types import TextFieldType
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
import pytest
|
||||
from django.db import models
|
||||
from faker import Faker
|
||||
|
||||
from baserow.contrib.database.fields.exceptions import (
|
||||
FieldTypeDoesNotExist, PrimaryFieldAlreadyExists, CannotDeletePrimaryField,
|
||||
FieldDoesNotExist, IncompatiblePrimaryFieldTypeError, CannotChangeFieldType
|
||||
)
|
||||
from baserow.contrib.database.fields.field_types import TextFieldType, LongTextFieldType
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
from baserow.contrib.database.fields.models import (
|
||||
Field, TextField, NumberField, BooleanField, SelectOption, LongTextField,
|
||||
NUMBER_TYPE_CHOICES
|
||||
)
|
||||
from baserow.contrib.database.fields.registries import field_type_registry
|
||||
from baserow.contrib.database.rows.handler import RowHandler
|
||||
from baserow.core.exceptions import UserNotInGroupError
|
||||
|
||||
|
||||
def dict_to_pairs(field_type_kwargs):
|
||||
pairs_dict = {}
|
||||
for name, options in field_type_kwargs.items():
|
||||
pairs_dict[name] = []
|
||||
if not isinstance(options, list):
|
||||
options = [options]
|
||||
for option in options:
|
||||
pairs_dict[name].append((name, option))
|
||||
return pairs_dict
|
||||
|
||||
|
||||
def construct_all_possible_kwargs(field_type_kwargs):
|
||||
pairs_dict = dict_to_pairs(field_type_kwargs)
|
||||
args = [dict(pairwise_args) for pairwise_args in itertools.product(
|
||||
*pairs_dict.values())]
|
||||
|
||||
return args
|
||||
|
||||
|
||||
# You must add --runslow to pytest to run this test, you can do this in intellij by
|
||||
# editing the run config for this test and adding --runslow to additional args.
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.slow
|
||||
def test_can_convert_between_all_fields(data_fixture):
|
||||
"""
|
||||
A nuclear option test turned off by default to help verify changes made to
|
||||
field conversions work in every possible conversion scenario. This test checks
|
||||
is possible to convert from every possible field to every other possible field
|
||||
including converting to themselves. It only checks that the conversion does not
|
||||
raise any exceptions.
|
||||
"""
|
||||
|
||||
user = data_fixture.create_user()
|
||||
database = data_fixture.create_database_application(user=user)
|
||||
table = data_fixture.create_database_table(database=database, user=user)
|
||||
link_table = data_fixture.create_database_table(database=database, user=user)
|
||||
handler = FieldHandler()
|
||||
row_handler = RowHandler()
|
||||
fake = Faker()
|
||||
|
||||
model = table.get_model()
|
||||
cache = {}
|
||||
# Make a blank row to test empty field conversion also.
|
||||
model.objects.create(**{})
|
||||
second_row_with_values = model.objects.create(**{})
|
||||
|
||||
# Some baserow field types have multiple different 'modes' which result in
|
||||
# different conversion behaviour or entirely different database columns being
|
||||
# created. Here the kwargs which control these modes are enumerated so we can then
|
||||
# generate every possible type of conversion.
|
||||
extra_kwargs_for_type = {
|
||||
'date': {
|
||||
'date_include_time': [True, False],
|
||||
},
|
||||
'number': {
|
||||
'number_type': [number_type for number_type, _ in NUMBER_TYPE_CHOICES],
|
||||
'number_negative': [True, False],
|
||||
},
|
||||
'link_row': {
|
||||
'link_row_table': link_table
|
||||
}
|
||||
}
|
||||
|
||||
all_possible_kwargs_per_type = {}
|
||||
for field_type_name in field_type_registry.get_types():
|
||||
extra_kwargs = extra_kwargs_for_type.get(field_type_name, {})
|
||||
all_possible_kwargs = construct_all_possible_kwargs(extra_kwargs)
|
||||
all_possible_kwargs_per_type[field_type_name] = all_possible_kwargs
|
||||
|
||||
i = 1
|
||||
for field_type_name, all_possible_kwargs in all_possible_kwargs_per_type.items():
|
||||
for kwargs in all_possible_kwargs:
|
||||
for inner_field_type_name in field_type_registry.get_types():
|
||||
for inner_kwargs in all_possible_kwargs_per_type[inner_field_type_name]:
|
||||
field_type = field_type_registry.get(field_type_name)
|
||||
field_name = f'field_{i}'
|
||||
from_field = handler.create_field(
|
||||
user=user, table=table, type_name=field_type_name,
|
||||
name=field_name,
|
||||
**kwargs
|
||||
)
|
||||
random_value = field_type.random_value(
|
||||
from_field,
|
||||
fake,
|
||||
cache
|
||||
)
|
||||
if isinstance(random_value, date):
|
||||
# Faker produces subtypes of date / datetime which baserow
|
||||
# does not want, instead just convert to str.
|
||||
random_value = str(random_value)
|
||||
row_handler.update_row(user=user, table=table,
|
||||
row_id=second_row_with_values.id,
|
||||
values={
|
||||
f'field_{from_field.id}': random_value
|
||||
})
|
||||
handler.update_field(user=user, field=from_field,
|
||||
new_type_name=inner_field_type_name,
|
||||
**inner_kwargs)
|
||||
i = i + 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
@ -269,6 +377,295 @@ def test_update_field_failing(data_fixture):
|
|||
assert TextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_field_when_underlying_sql_type_doesnt_change(data_fixture):
|
||||
class AlwaysLowercaseTextField(TextFieldType):
|
||||
type = 'lowercase_text'
|
||||
model_class = LongTextField
|
||||
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (lower(p_in));'''
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_text_field = data_fixture.create_text_field(table=table, order=1)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_text_field.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{'lowercase_text': AlwaysLowercaseTextField()}
|
||||
):
|
||||
handler.update_field(user=user,
|
||||
field=existing_text_field,
|
||||
new_type_name='lowercase_text')
|
||||
|
||||
row.refresh_from_db()
|
||||
assert getattr(row, field_name) == 'test'
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 0
|
||||
assert LongTextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_field_which_changes_its_underlying_type_will_have_alter_sql_run(data_fixture):
|
||||
class ReversingTextFieldUsingBothVarCharAndTextSqlTypes(TextFieldType):
|
||||
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (reverse(p_in));'''
|
||||
|
||||
def get_model_field(self, instance, **kwargs):
|
||||
kwargs['null'] = True
|
||||
kwargs['blank'] = True
|
||||
if instance.text_default == 'use_other_sql_type':
|
||||
return models.TextField(**kwargs)
|
||||
else:
|
||||
return models.CharField(**kwargs)
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_text_field = data_fixture.create_text_field(table=table, order=1)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_text_field.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{'text': ReversingTextFieldUsingBothVarCharAndTextSqlTypes()}
|
||||
):
|
||||
# Update to the same baserow type, but due to this fields implementation of
|
||||
# get_model_field this will alter the underlying database column from type
|
||||
# of varchar to text, which should make our reversing alter sql run.
|
||||
handler.update_field(user=user,
|
||||
field=existing_text_field,
|
||||
new_type_name='text',
|
||||
text_default='use_other_sql_type')
|
||||
|
||||
row.refresh_from_db()
|
||||
assert getattr(row, field_name) == 'tseT'
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_just_changing_a_fields_name_will_not_run_alter_sql(data_fixture):
|
||||
class AlwaysReverseOnUpdateField(TextFieldType):
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (reverse(p_in));'''
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_text_field = data_fixture.create_text_field(table=table, order=1)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_text_field.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{'text': AlwaysReverseOnUpdateField()}
|
||||
):
|
||||
handler.update_field(user=user, field=existing_text_field,
|
||||
new_type_name='text', name='new_name')
|
||||
|
||||
row.refresh_from_db()
|
||||
# The field has not been reversed as just the name changed!
|
||||
assert getattr(row, field_name) == 'Test'
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_when_field_type_forces_same_type_alter_fields_alter_sql_is_run(data_fixture):
|
||||
class SameTypeAlwaysReverseOnUpdateField(TextFieldType):
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (reverse(p_in));'''
|
||||
|
||||
def force_same_type_alter_column(self, from_field, to_field):
|
||||
return True
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_text_field = data_fixture.create_text_field(table=table, order=1)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_text_field.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{'text': SameTypeAlwaysReverseOnUpdateField()}
|
||||
):
|
||||
handler.update_field(user=user, field=existing_text_field,
|
||||
new_type_name='text', name='new_name')
|
||||
|
||||
row.refresh_from_db()
|
||||
# The alter sql has been run due to the force override
|
||||
assert getattr(row, field_name) == 'tseT'
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_field_with_type_error_on_conversion_should_null_field(data_fixture):
|
||||
class AlwaysThrowsSqlExceptionOnConversionField(TextFieldType):
|
||||
type = 'throws_field'
|
||||
model_class = LongTextField
|
||||
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (lower(p_in::numeric::text));'''
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_text_field = data_fixture.create_text_field(table=table, order=1)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_text_field.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{'throws_field': AlwaysThrowsSqlExceptionOnConversionField()}
|
||||
):
|
||||
handler.update_field(user=user,
|
||||
field=existing_text_field,
|
||||
new_type_name='throws_field')
|
||||
|
||||
row.refresh_from_db()
|
||||
assert getattr(row, field_name) is None
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 0
|
||||
assert LongTextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_field_when_underlying_sql_type_doesnt_change_with_vars(data_fixture):
|
||||
class ReversesWhenConvertsAwayTextField(LongTextFieldType):
|
||||
type = 'reserves_text'
|
||||
model_class = LongTextField
|
||||
|
||||
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
|
||||
return '''p_in = concat(reverse(p_in), %(some_variable)s);''', {
|
||||
"some_variable": "_POST_FIX"
|
||||
}
|
||||
|
||||
class AlwaysLowercaseTextField(TextFieldType):
|
||||
type = 'lowercase_text'
|
||||
model_class = LongTextField
|
||||
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = concat(%(other_variable)s, lower(p_in));''', {
|
||||
"other_variable": "pre_fix_"
|
||||
}
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_field_with_old_value_prep = data_fixture.create_long_text_field(
|
||||
table=table)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_field_with_old_value_prep.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{
|
||||
'lowercase_text': AlwaysLowercaseTextField(),
|
||||
'long_text': ReversesWhenConvertsAwayTextField()
|
||||
}
|
||||
):
|
||||
handler.update_field(user=user,
|
||||
field=existing_field_with_old_value_prep,
|
||||
new_type_name='lowercase_text')
|
||||
|
||||
row.refresh_from_db()
|
||||
assert getattr(row, field_name) == 'pre_fix_tset_post_fix'
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 0
|
||||
assert LongTextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_field_when_underlying_sql_type_doesnt_change_old_prep(data_fixture):
|
||||
class ReversesWhenConvertsAwayTextField(LongTextFieldType):
|
||||
type = 'reserves_text'
|
||||
model_class = LongTextField
|
||||
|
||||
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (reverse(p_in));'''
|
||||
|
||||
class AlwaysLowercaseTextField(TextFieldType):
|
||||
type = 'lowercase_text'
|
||||
model_class = LongTextField
|
||||
|
||||
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
|
||||
return '''p_in = (lower(p_in));'''
|
||||
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
existing_field_with_old_value_prep = data_fixture.create_long_text_field(
|
||||
table=table)
|
||||
|
||||
model = table.get_model()
|
||||
|
||||
field_name = f'field_{existing_field_with_old_value_prep.id}'
|
||||
row = model.objects.create(**{
|
||||
field_name: 'Test',
|
||||
})
|
||||
|
||||
handler = FieldHandler()
|
||||
|
||||
with patch.dict(
|
||||
field_type_registry.registry,
|
||||
{
|
||||
'lowercase_text': AlwaysLowercaseTextField(),
|
||||
'long_text': ReversesWhenConvertsAwayTextField()
|
||||
}
|
||||
):
|
||||
handler.update_field(user=user,
|
||||
field=existing_field_with_old_value_prep,
|
||||
new_type_name='lowercase_text')
|
||||
|
||||
row.refresh_from_db()
|
||||
assert getattr(row, field_name) == 'tset'
|
||||
assert Field.objects.all().count() == 1
|
||||
assert TextField.objects.all().count() == 0
|
||||
assert LongTextField.objects.all().count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch('baserow.contrib.database.fields.signals.field_deleted.send')
|
||||
def test_delete_field(send_mock, data_fixture):
|
||||
|
|
|
@ -1,15 +1,18 @@
|
|||
import pytest
|
||||
import json
|
||||
|
||||
from django.test.utils import override_settings
|
||||
from faker import Faker
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from baserow.contrib.database.fields.field_types import PhoneNumberFieldType
|
||||
from baserow.core.user_files.exceptions import (
|
||||
InvalidUserFileNameError, UserFileDoesNotExist
|
||||
)
|
||||
from baserow.contrib.database.fields.models import (
|
||||
LongTextField, URLField, EmailField, FileField
|
||||
LongTextField, URLField, EmailField, FileField, PhoneNumberField
|
||||
)
|
||||
from baserow.contrib.database.fields.handler import FieldHandler
|
||||
from baserow.contrib.database.rows.handler import RowHandler
|
||||
|
@ -511,3 +514,102 @@ def test_file_field_type(data_fixture):
|
|||
assert results[0].text is None
|
||||
assert results[1].text is None
|
||||
assert results[2].text is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(debug=True)
|
||||
def test_phone_number_field_type(data_fixture):
|
||||
user = data_fixture.create_user()
|
||||
table = data_fixture.create_database_table(user=user)
|
||||
data_fixture.create_database_table(user=user, database=table.database)
|
||||
|
||||
field_handler = FieldHandler()
|
||||
row_handler = RowHandler()
|
||||
|
||||
text_field = field_handler.create_field(user=user, table=table,
|
||||
order=1,
|
||||
type_name='text',
|
||||
name='name')
|
||||
phone_number_field = field_handler.create_field(user=user, table=table,
|
||||
type_name='phone_number',
|
||||
name='phonenumber')
|
||||
email_field = field_handler.create_field(user=user, table=table,
|
||||
type_name='email',
|
||||
name='email')
|
||||
number_field = data_fixture.create_number_field(table=table, order=1,
|
||||
number_negative=True, name="number")
|
||||
|
||||
assert len(PhoneNumberField.objects.all()) == 1
|
||||
model = table.get_model(attribute_names=True)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'phonenumber': 'invalid phone number'
|
||||
}, model=model)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'phonenumber': 'Phone: 2312321 2349432 '
|
||||
}, model=model)
|
||||
with pytest.raises(ValidationError):
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'phonenumber': '1' * (PhoneNumberFieldType.MAX_PHONE_NUMBER_LENGTH+1)
|
||||
}, model=model)
|
||||
|
||||
max_length_phone_number = '1' * PhoneNumberFieldType.MAX_PHONE_NUMBER_LENGTH
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'name': '+45(1424) 322314 324234',
|
||||
'phonenumber': max_length_phone_number,
|
||||
'number': 1234534532,
|
||||
'email': 'a_valid_email_to_be_blanked_after_conversion@email.com'
|
||||
}, model=model)
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'name': 'some text which should be blanked out after conversion',
|
||||
'phonenumber': '1234567890 NnXx,+._*()#=;/ -',
|
||||
'number': 0
|
||||
}, model=model)
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'name': max_length_phone_number,
|
||||
'phonenumber': '',
|
||||
'number': -10230450,
|
||||
}, model=model)
|
||||
row_handler.create_row(user=user, table=table, values={
|
||||
'phonenumber': None,
|
||||
'name': '1' * (PhoneNumberFieldType.MAX_PHONE_NUMBER_LENGTH+1)
|
||||
|
||||
}, model=model)
|
||||
row_handler.create_row(user=user, table=table, values={}, model=model)
|
||||
|
||||
# No actual database type change occurs here as a phone number field is also a text
|
||||
# field. Instead the after_update hook is being used to clear out invalid
|
||||
# phone numbers.
|
||||
field_handler.update_field(user=user, field=text_field,
|
||||
new_type_name='phone_number')
|
||||
|
||||
field_handler.update_field(user=user, field=number_field,
|
||||
new_type_name='phone_number')
|
||||
field_handler.update_field(user=user, field=email_field,
|
||||
new_type_name='phone_number')
|
||||
|
||||
model = table.get_model(attribute_names=True)
|
||||
rows = model.objects.all()
|
||||
|
||||
assert rows[0].name == '+45(1424) 322314 324234'
|
||||
assert rows[0].phonenumber == max_length_phone_number
|
||||
assert rows[0].number == '1234534532'
|
||||
assert rows[0].email == ''
|
||||
|
||||
assert rows[1].name == ''
|
||||
assert rows[1].phonenumber == '1234567890 NnXx,+._*()#=;/ -'
|
||||
assert rows[1].number == '0'
|
||||
|
||||
assert rows[2].name == max_length_phone_number
|
||||
assert rows[2].phonenumber == ''
|
||||
assert rows[2].number == '-10230450'
|
||||
|
||||
assert rows[3].name == ''
|
||||
assert rows[3].phonenumber == ''
|
||||
assert rows[3].number == ''
|
||||
|
||||
field_handler.delete_field(user=user, field=phone_number_field)
|
||||
assert len(PhoneNumberField.objects.all()) == 3
|
||||
|
|
|
@ -161,12 +161,13 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db):
|
|||
date_format="US", date_include_time=True,
|
||||
date_time_format="24")
|
||||
data_fixture.create_file_field(table=table, order=6, name='File')
|
||||
select = data_fixture.create_single_select_field(table=table, order=6,
|
||||
select = data_fixture.create_single_select_field(table=table, order=7,
|
||||
name='select')
|
||||
option_a = data_fixture.create_select_option(field=select, value='Option A',
|
||||
color='blue')
|
||||
option_b = data_fixture.create_select_option(field=select, value='Option B',
|
||||
color='red')
|
||||
data_fixture.create_phone_number_field(table=table, order=8, name='PhoneNumber')
|
||||
|
||||
model = table.get_model(attribute_names=True)
|
||||
row_1 = model.objects.create(
|
||||
|
@ -178,6 +179,7 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db):
|
|||
datetime=make_aware(datetime(4006, 7, 8, 0, 0, 0), utc),
|
||||
file=[{'visible_name': 'test_file.png'}],
|
||||
select=option_a,
|
||||
phonenumber='99999'
|
||||
)
|
||||
row_2 = model.objects.create(
|
||||
name='Audi',
|
||||
|
@ -188,6 +190,7 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db):
|
|||
datetime=make_aware(datetime(5, 5, 5, 0, 48, 0), utc),
|
||||
file=[{'visible_name': 'other_file.png'}],
|
||||
select=option_b,
|
||||
phonenumber='++--999999'
|
||||
)
|
||||
row_3 = model.objects.create(
|
||||
name='Volkswagen',
|
||||
|
@ -197,6 +200,7 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db):
|
|||
date='9999-05-05',
|
||||
datetime=make_aware(datetime(5, 5, 5, 9, 59, 0), utc),
|
||||
file=[],
|
||||
phonenumber=''
|
||||
)
|
||||
|
||||
results = model.objects.all().search_all_fields('FASTEST')
|
||||
|
@ -260,6 +264,15 @@ def test_search_all_fields_queryset(data_fixture, user_tables_in_separate_db):
|
|||
assert len(results) == 1
|
||||
assert row_2 in results
|
||||
|
||||
results = model.objects.all().search_all_fields('999999')
|
||||
assert len(results) == 1
|
||||
assert row_2 in results
|
||||
|
||||
results = model.objects.all().search_all_fields('99999')
|
||||
assert len(results) == 2
|
||||
assert row_1 in results
|
||||
assert row_2 in results
|
||||
|
||||
results = model.objects.all().search_all_fields('white car')
|
||||
assert len(results) == 0
|
||||
|
||||
|
|
|
@ -18,6 +18,26 @@ def api_client():
|
|||
return APIClient()
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--runslow", action="store_true", default=False, help="run slow tests"
|
||||
)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "slow: mark test as slow to run")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if config.getoption("--runslow"):
|
||||
# --runslow given in cli: do not skip slow tests
|
||||
return
|
||||
skip_slow = pytest.mark.skip(reason="need --runslow option to run")
|
||||
for item in items:
|
||||
if "slow" in item.keywords:
|
||||
item.add_marker(skip_slow)
|
||||
|
||||
|
||||
def run_non_transactional_raw_sql(sqls, dbinfo):
|
||||
conn = psycopg2.connect(host=dbinfo['HOST'], user=dbinfo['USER'],
|
||||
password=dbinfo['PASSWORD'],
|
||||
|
|
53
backend/tests/fixtures/field.py
vendored
53
backend/tests/fixtures/field.py
vendored
|
@ -3,7 +3,7 @@ from django.db import connections
|
|||
|
||||
from baserow.contrib.database.fields.models import (
|
||||
TextField, LongTextField, NumberField, BooleanField, DateField, LinkRowField,
|
||||
FileField, SingleSelectField, SelectOption
|
||||
FileField, SingleSelectField, SelectOption, URLField, EmailField, PhoneNumberField
|
||||
)
|
||||
|
||||
|
||||
|
@ -167,3 +167,54 @@ class FieldFixtures:
|
|||
self.create_model_field(kwargs['table'], field)
|
||||
|
||||
return field
|
||||
|
||||
def create_url_field(self, user=None, create_field=True, **kwargs):
|
||||
if 'table' not in kwargs:
|
||||
kwargs['table'] = self.create_database_table(user=user)
|
||||
|
||||
if 'name' not in kwargs:
|
||||
kwargs['name'] = self.fake.url()
|
||||
|
||||
if 'order' not in kwargs:
|
||||
kwargs['order'] = 0
|
||||
|
||||
field = URLField.objects.create(**kwargs)
|
||||
|
||||
if create_field:
|
||||
self.create_model_field(kwargs['table'], field)
|
||||
|
||||
return field
|
||||
|
||||
def create_email_field(self, user=None, create_field=True, **kwargs):
|
||||
if 'table' not in kwargs:
|
||||
kwargs['table'] = self.create_database_table(user=user)
|
||||
|
||||
if 'name' not in kwargs:
|
||||
kwargs['name'] = self.fake.email()
|
||||
|
||||
if 'order' not in kwargs:
|
||||
kwargs['order'] = 0
|
||||
|
||||
field = EmailField.objects.create(**kwargs)
|
||||
|
||||
if create_field:
|
||||
self.create_model_field(kwargs['table'], field)
|
||||
|
||||
return field
|
||||
|
||||
def create_phone_number_field(self, user=None, create_field=True, **kwargs):
|
||||
if 'table' not in kwargs:
|
||||
kwargs['table'] = self.create_database_table(user=user)
|
||||
|
||||
if 'name' not in kwargs:
|
||||
kwargs['name'] = self.fake.phone_number()
|
||||
|
||||
if 'order' not in kwargs:
|
||||
kwargs['order'] = 0
|
||||
|
||||
field = PhoneNumberField.objects.create(**kwargs)
|
||||
|
||||
if create_field:
|
||||
self.create_model_field(kwargs['table'], field)
|
||||
|
||||
return field
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
* Fixed SSRF bug in the file upload by URL by blocking urls to the private network.
|
||||
* Fixed bug where an invalid date could be converted to 0001-01-01.
|
||||
* The list_database_table_rows search query parameter now searches all possible field types.
|
||||
* Add Phone Number field.
|
||||
|
||||
## Released (2021-03-01)
|
||||
|
||||
|
|
107
docs/decisions/001-phone-number-field-validation.md
Normal file
107
docs/decisions/001-phone-number-field-validation.md
Normal file
|
@ -0,0 +1,107 @@
|
|||
# How to Validate and Format Phone Number Fields
|
||||
|
||||
See this issue for background: https://gitlab.com/bramw/baserow/-/issues/339
|
||||
|
||||
For the new number phone number field there are a number of options around how to
|
||||
validate and format the field. This document captures
|
||||
|
||||
# Decision
|
||||
|
||||
Option 1 was chosen because:
|
||||
|
||||
- It is the simplest and there are no specific user requests for any of the more complex
|
||||
options below, so instead we stick to the simple option.
|
||||
- We do not need to reason about the validation provided by two different open source
|
||||
libraries and test that they agree which numbers are valid or not.
|
||||
|
||||
## Option 1:
|
||||
|
||||
Use a simple regex only allowing numbers, spaces and some punctuation to validate phone
|
||||
numbers. Assume every entered phone number which passes this regex is a valid number and
|
||||
display the number as a link (href="tel: {{value}}")
|
||||
when appropriate so the user can call the number.
|
||||
|
||||
### Pros:
|
||||
|
||||
- Simplest technically
|
||||
- No need to use external libraries
|
||||
- Most flexible for the user as they can enter any type of telephone format
|
||||
- We might be able to pass on this "validate the phone number" problem to the phone as
|
||||
if it is formatted as a href="tel: xxx" link the phone might then format the opened
|
||||
link
|
||||
|
||||
### Cons:
|
||||
|
||||
- Users can easily enter complete nonsense for phone numbers
|
||||
- Its upto the user to nicely format the phone numbers every single time
|
||||
|
||||
## Option 2:
|
||||
|
||||
Use a [python](https://github.com/daviddrysdale/python-phonenumbers)
|
||||
and [js library](https://github.com/catamphetamine/libphonenumber-js) both based
|
||||
off [google's phonenumberlib](https://github.com/google/libphonenumber) and validate
|
||||
that a number is "possible" which is a lower standard and less likely to change compared
|
||||
to "
|
||||
valid". Auto format the number based on it could be using any country code.
|
||||
|
||||
### Pros
|
||||
|
||||
- Dont need to store/allow configuring extra country code information
|
||||
- Nice formatting for international numbers
|
||||
- By using the least strict form of validation provided by the libraries we can be more
|
||||
sure that the validations match between client and server.
|
||||
|
||||
### Cons
|
||||
|
||||
- Dont get nice country specific formatting unless the user enters a country code in the
|
||||
number itself as otherwise the libraries cant detect what the country is.
|
||||
|
||||
## Option 3:
|
||||
|
||||
Option 2 but also allow user to specify a country code OR "international" on the whole
|
||||
field, format and validate numbers entered using this country code.
|
||||
|
||||
### Pros
|
||||
|
||||
- Get nice formatting for local numbers if you set the column
|
||||
|
||||
### Cons
|
||||
|
||||
- Limits users to only having one telephone format per column, cant mix numbers without
|
||||
using international
|
||||
- Have to implement / design a country code select mechanism
|
||||
- Have to store and sync extra country code data on the field
|
||||
|
||||
## Option 4:
|
||||
|
||||
Option 2, but also allow user to set a default country code on the field and then let
|
||||
the user pick a country code per row which defaults to the fields default.
|
||||
|
||||
### Pros
|
||||
|
||||
- Users can mix every possible type of phone number in a single field and get nice
|
||||
formatting and validation.
|
||||
- If they dont want to mix then they can fallback to option 2 by using a default code
|
||||
- If they dont want any country specific formatting they can fallback to option 1
|
||||
|
||||
### Cons
|
||||
|
||||
- Have to store country data per field and row
|
||||
- Have to design a row entry component which uses the default + lets users pick a
|
||||
country code per field + column
|
||||
|
||||
## Option 5:
|
||||
|
||||
Only use a front-end library to format the entered phone numbers and check they are
|
||||
posible purely in the front-end, the backend does simple or no validation.
|
||||
|
||||
### Pros
|
||||
|
||||
- Don't need to worry about syncing the front and back end validation
|
||||
- Still get nice phone number entry and formatting
|
||||
|
||||
### Cons
|
||||
|
||||
- When a user converts a non-phone number field to being a phone number this happens on
|
||||
the backend and hence no nice formatting will happen. Then when the user edits one
|
||||
of these unformatted cells it will instantly change to be formatted.
|
|
@ -53,6 +53,15 @@ export const isValidEmail = (str) => {
|
|||
return !!pattern.test(str)
|
||||
}
|
||||
|
||||
// Regex duplicated from
|
||||
// src/baserow/contrib/database/fields/field_types.py#PhoneNumberFieldType
|
||||
// Docs reference what characters are valid in PhoneNumberFieldType.getDocsDescription
|
||||
// Ensure they are kept in sync.
|
||||
export const isSimplePhoneNumber = (str) => {
|
||||
const pattern = /^[0-9NnXx,+._*()#=;/ -]{1,100}$/
|
||||
return pattern.test(str)
|
||||
}
|
||||
|
||||
export const isSecureURL = (str) => {
|
||||
return str.toLowerCase().substr(0, 5) === 'https'
|
||||
}
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<div class="control__elements">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="copy"
|
||||
type="tel"
|
||||
class="input input--large"
|
||||
:class="{ 'input--error': !isValid() }"
|
||||
@keyup.enter="$refs.input.blur()"
|
||||
@focus="select()"
|
||||
@blur="unselect()"
|
||||
/>
|
||||
<div v-show="!isValid()" class="error">
|
||||
{{ getError() }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
|
||||
import rowEditFieldInput from '@baserow/modules/database/mixins/rowEditFieldInput'
|
||||
import phoneNumberField from '@baserow/modules/database/mixins/phoneNumberField'
|
||||
|
||||
export default {
|
||||
mixins: [rowEditField, rowEditFieldInput, phoneNumberField],
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,7 @@
|
|||
<template functional>
|
||||
<div ref="cell" class="grid-view__cell">
|
||||
<div class="grid-field-text">
|
||||
{{ props.value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<div
|
||||
class="grid-view__cell active"
|
||||
:class="{
|
||||
editing: editing,
|
||||
invalid: editing && !isValid(),
|
||||
}"
|
||||
@contextmenu="stopContextIfEditing($event)"
|
||||
>
|
||||
<div v-show="!editing" class="grid-field-text">
|
||||
<a :href="'tel:' + value" target="_blank">{{ value }}</a>
|
||||
</div>
|
||||
<template v-if="editing">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="copy"
|
||||
type="tel"
|
||||
class="grid-field-text__input"
|
||||
/>
|
||||
<div v-show="!isValid()" class="grid-view__cell--error align-right">
|
||||
{{ getError() }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import gridField from '@baserow/modules/database/mixins/gridField'
|
||||
import gridFieldInput from '@baserow/modules/database/mixins/gridFieldInput'
|
||||
import phoneNumberField from '@baserow/modules/database/mixins/phoneNumberField'
|
||||
|
||||
export default {
|
||||
mixins: [gridField, gridFieldInput, phoneNumberField],
|
||||
methods: {
|
||||
afterEdit() {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.input.focus()
|
||||
this.$refs.input.selectionStart = this.$refs.input.selectionEnd = 100000
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -1,7 +1,11 @@
|
|||
import moment from 'moment'
|
||||
import BigNumber from 'bignumber.js'
|
||||
|
||||
import { isValidURL, isValidEmail } from '@baserow/modules/core/utils/string'
|
||||
import {
|
||||
isValidURL,
|
||||
isValidEmail,
|
||||
isSimplePhoneNumber,
|
||||
} from '@baserow/modules/core/utils/string'
|
||||
import { Registerable } from '@baserow/modules/core/registry'
|
||||
|
||||
import FieldNumberSubForm from '@baserow/modules/database/components/field/FieldNumberSubForm'
|
||||
|
@ -20,6 +24,7 @@ import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid
|
|||
import GridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/GridViewFieldDate'
|
||||
import GridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/GridViewFieldFile'
|
||||
import GridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/fields/GridViewFieldSingleSelect'
|
||||
import GridViewFieldPhoneNumber from '@baserow/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber'
|
||||
|
||||
import FunctionalGridViewFieldText from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldText'
|
||||
import FunctionalGridViewFieldLongText from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldLongText'
|
||||
|
@ -29,6 +34,7 @@ import FunctionalGridViewFieldBoolean from '@baserow/modules/database/components
|
|||
import FunctionalGridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldDate'
|
||||
import FunctionalGridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldFile'
|
||||
import FunctionalGridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldSingleSelect'
|
||||
import FunctionalGridViewFieldPhoneNumber from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber'
|
||||
|
||||
import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText'
|
||||
import RowEditFieldLongText from '@baserow/modules/database/components/row/RowEditFieldLongText'
|
||||
|
@ -40,6 +46,7 @@ import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEdi
|
|||
import RowEditFieldDate from '@baserow/modules/database/components/row/RowEditFieldDate'
|
||||
import RowEditFieldFile from '@baserow/modules/database/components/row/RowEditFieldFile'
|
||||
import RowEditFieldSingleSelect from '@baserow/modules/database/components/row/RowEditFieldSingleSelect'
|
||||
import RowEditFieldPhoneNumber from '@baserow/modules/database/components/row/RowEditFieldPhoneNumber'
|
||||
|
||||
import { trueString } from '@baserow/modules/database/utils/constants'
|
||||
import {
|
||||
|
@ -1129,3 +1136,65 @@ export class SingleSelectFieldType extends FieldType {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PhoneNumberFieldType extends FieldType {
|
||||
static getType() {
|
||||
return 'phone_number'
|
||||
}
|
||||
|
||||
getIconClass() {
|
||||
return 'phone'
|
||||
}
|
||||
|
||||
getName() {
|
||||
return 'Phone Number'
|
||||
}
|
||||
|
||||
getGridViewFieldComponent() {
|
||||
return GridViewFieldPhoneNumber
|
||||
}
|
||||
|
||||
getFunctionalGridViewFieldComponent() {
|
||||
return FunctionalGridViewFieldPhoneNumber
|
||||
}
|
||||
|
||||
getRowEditFieldComponent() {
|
||||
return RowEditFieldPhoneNumber
|
||||
}
|
||||
|
||||
prepareValueForPaste(field, clipboardData) {
|
||||
const value = clipboardData.getData('text')
|
||||
return isSimplePhoneNumber(value) ? value : ''
|
||||
}
|
||||
|
||||
getSort(name, order) {
|
||||
return (a, b) => {
|
||||
const stringA = a[name] === null ? '' : '' + a[name]
|
||||
const stringB = b[name] === null ? '' : '' + b[name]
|
||||
|
||||
return order === 'ASC'
|
||||
? stringA.localeCompare(stringB)
|
||||
: stringB.localeCompare(stringA)
|
||||
}
|
||||
}
|
||||
|
||||
getSortIndicator() {
|
||||
return ['text', '0', '9']
|
||||
}
|
||||
|
||||
getDocsDataType(field) {
|
||||
return 'string'
|
||||
}
|
||||
|
||||
getDocsDescription(field) {
|
||||
return (
|
||||
'Accepts a phone number which has a maximum length of 100 characters' +
|
||||
' consisting solely of digits, spaces and the following characters: ' +
|
||||
'Nx,._+*()#=;/- .'
|
||||
)
|
||||
}
|
||||
|
||||
getDocsRequestExample(field) {
|
||||
return '+1-541-754-3010'
|
||||
}
|
||||
}
|
||||
|
|
26
web-frontend/modules/database/mixins/phoneNumberField.js
Normal file
26
web-frontend/modules/database/mixins/phoneNumberField.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* This mixin contains some method overrides for validating and formatting the
|
||||
* phone number field. This mixin is used in both the GridViewFieldPhoneNumber and
|
||||
* RowEditFieldPhoneNumber components.
|
||||
*/
|
||||
import { isSimplePhoneNumber } from '@baserow/modules/core/utils/string'
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
/**
|
||||
* Generates a human readable error for the user if something is wrong.
|
||||
*/
|
||||
getError() {
|
||||
if (this.copy === null || this.copy === '') {
|
||||
return null
|
||||
}
|
||||
if (!isSimplePhoneNumber(this.copy)) {
|
||||
return 'Invalid Phone Number'
|
||||
}
|
||||
return null
|
||||
},
|
||||
isValid() {
|
||||
return this.getError() === null
|
||||
},
|
||||
},
|
||||
}
|
|
@ -11,6 +11,7 @@ import {
|
|||
DateFieldType,
|
||||
FileFieldType,
|
||||
SingleSelectFieldType,
|
||||
PhoneNumberFieldType,
|
||||
} from '@baserow/modules/database/fieldTypes'
|
||||
import {
|
||||
EqualViewFilterType,
|
||||
|
@ -73,6 +74,7 @@ export default ({ store, app }) => {
|
|||
app.$registry.register('field', new EmailFieldType())
|
||||
app.$registry.register('field', new FileFieldType())
|
||||
app.$registry.register('field', new SingleSelectFieldType())
|
||||
app.$registry.register('field', new PhoneNumberFieldType())
|
||||
app.$registry.register('importer', new CSVImporterType())
|
||||
app.$registry.register('importer', new PasteImporterType())
|
||||
app.$registry.register('settings', new APITokenSettingsType())
|
||||
|
|
|
@ -98,7 +98,7 @@ export class EqualViewFilterType extends ViewFilterType {
|
|||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'url', 'email', 'number']
|
||||
return ['text', 'long_text', 'url', 'email', 'number', 'phone_number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
|
@ -126,7 +126,7 @@ export class NotEqualViewFilterType extends ViewFilterType {
|
|||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'url', 'email', 'number']
|
||||
return ['text', 'long_text', 'url', 'email', 'number', 'phone_number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
|
@ -193,7 +193,7 @@ export class ContainsViewFilterType extends ViewFilterType {
|
|||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'url', 'email']
|
||||
return ['text', 'long_text', 'url', 'email', 'phone_number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
|
@ -217,7 +217,7 @@ export class ContainsNotViewFilterType extends ViewFilterType {
|
|||
}
|
||||
|
||||
getCompatibleFieldTypes() {
|
||||
return ['text', 'long_text', 'url', 'email']
|
||||
return ['text', 'long_text', 'url', 'email', 'phone_number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
|
@ -475,6 +475,7 @@ export class EmptyViewFilterType extends ViewFilterType {
|
|||
'link_row',
|
||||
'file',
|
||||
'single_select',
|
||||
'phone_number',
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -517,6 +518,7 @@ export class NotEmptyViewFilterType extends ViewFilterType {
|
|||
'link_row',
|
||||
'file',
|
||||
'single_select',
|
||||
'phone_number',
|
||||
]
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue