1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Merge branch '104-url-field' into 'develop'

Resolve "URL field"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2020-09-28 07:19:52 +00:00
commit 2f8a41f0c1
15 changed files with 424 additions and 15 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
changelog.md
web-frontend/modules

View file

@ -44,11 +44,12 @@ class DatabaseConfig(AppConfig):
plugin_registry.register(DatabasePlugin())
from .fields.field_types import (
TextFieldType, LongTextFieldType, NumberFieldType, BooleanFieldType,
DateFieldType, LinkRowFieldType
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType,
BooleanFieldType, DateFieldType, LinkRowFieldType
)
field_type_registry.register(TextFieldType())
field_type_registry.register(LongTextFieldType())
field_type_registry.register(URLFieldType())
field_type_registry.register(NumberFieldType())
field_type_registry.register(BooleanFieldType())
field_type_registry.register(DateFieldType())

View file

@ -6,6 +6,7 @@ from dateutil.parser import ParserError
from datetime import datetime, date
from django.db import models
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.utils.timezone import make_aware
@ -19,8 +20,8 @@ from baserow.contrib.database.api.fields.errors import (
from .handler import FieldHandler
from .registries import FieldType
from .models import (
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, NumberField,
BooleanField, DateField, LinkRowField
NUMBER_TYPE_INTEGER, NUMBER_TYPE_DECIMAL, TextField, LongTextField, URLField,
NumberField, BooleanField, DateField, LinkRowField
)
from .exceptions import LinkRowTableNotInSameDatabase, LinkRowTableNotProvided
@ -58,6 +59,41 @@ class LongTextFieldType(FieldType):
return fake.text()
class URLFieldType(FieldType):
type = 'url'
model_class = URLField
def prepare_value_for_db(self, instance, value):
if value == '' or value is None:
return ''
validator = URLValidator()
validator(value)
return value
def get_serializer_field(self, instance, **kwargs):
return serializers.URLField(required=False, allow_null=True, allow_blank=True,
**kwargs)
def get_model_field(self, instance, **kwargs):
return models.URLField(default='', blank=True, null=True, **kwargs)
def random_value(self, instance, fake, cache):
return fake.url()
def get_alter_column_type_function(self, connection, instance):
if connection.vendor == 'postgresql':
return r"""(
case
when p_in::text ~* '(https?|ftps?)://(-\.)?([^\s/?\.#-]+\.?)+(/[^\s]*)?'
then p_in::text
else ''
end
)"""
return super().get_alter_column_type_function(connection, instance)
class NumberFieldType(FieldType):
MAX_DIGITS = 50

View file

@ -119,6 +119,10 @@ class LongTextField(Field):
pass
class URLField(Field):
pass
class NumberField(Field):
number_type = models.CharField(
max_length=32,

View file

@ -0,0 +1,28 @@
# Generated by Django 2.2.11 on 2020-09-27 18:21
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('database', '0012_auto_20200904_1410'),
]
operations = [
migrations.CreateModel(
name='URLField',
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'
)),
],
bases=('database.field',),
),
]

View file

@ -9,8 +9,8 @@ from django.db.models import Q, IntegerField, BooleanField
from django.db.models.fields.related import ManyToManyField
from baserow.contrib.database.fields.field_types import (
TextFieldType, LongTextFieldType, NumberFieldType, DateFieldType, LinkRowFieldType,
BooleanFieldType
TextFieldType, LongTextFieldType, URLFieldType, NumberFieldType, DateFieldType,
LinkRowFieldType, BooleanFieldType
)
from .registries import ViewFilterType
@ -33,6 +33,7 @@ class EqualViewFilterType(ViewFilterType):
compatible_field_types = [
TextFieldType.type,
LongTextFieldType.type,
URLFieldType.type,
NumberFieldType.type,
BooleanFieldType.type
]
@ -67,7 +68,8 @@ class ContainsViewFilterType(ViewFilterType):
type = 'contains'
compatible_field_types = [
TextFieldType.type,
LongTextFieldType.type
LongTextFieldType.type,
URLFieldType.type,
]
def get_filter(self, field_name, value, model_field):
@ -242,6 +244,7 @@ class EmptyViewFilterType(ViewFilterType):
compatible_field_types = [
TextFieldType.type,
LongTextFieldType.type,
URLFieldType.type,
NumberFieldType.type,
BooleanFieldType.type,
DateFieldType.type,

View file

@ -7,7 +7,7 @@ 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, DateField
from baserow.contrib.database.fields.models import LongTextField, URLField, DateField
@pytest.mark.django_db
@ -126,6 +126,97 @@ def test_long_text_field_type(api_client, data_fixture):
assert LongTextField.objects.all().count() == 0
@pytest.mark.django_db
def test_url_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': 'URL', 'type': 'url'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json['type'] == 'url'
assert URLField.objects.all().count() == 1
field_id = response_json['id']
response = api_client.patch(
reverse('api:database:fields:item', kwargs={'field_id': field_id}),
{'name': 'URL2'},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response = api_client.post(
reverse('api:database:rows:list', kwargs={'table_id': table.id}),
{
f'field_{field_id}': 'https://test.nl'
},
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}'] == 'https://test.nl'
model = table.get_model(attribute_names=True)
row = model.objects.all().last()
assert row.url2 == 'https://test.nl'
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.url2 == ''
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.url2 == ''
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.url2 == ''
url = reverse('api:database:fields:item', kwargs={'field_id': field_id})
response = api_client.delete(url, HTTP_AUTHORIZATION=f'JWT {token}')
assert response.status_code == HTTP_204_NO_CONTENT
assert URLField.objects.all().count() == 0
@pytest.mark.django_db
def test_date_field_type(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(

View file

@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
from django.utils.timezone import make_aware, datetime
from baserow.contrib.database.fields.field_types import DateFieldType
from baserow.contrib.database.fields.models import LongTextField, DateField
from baserow.contrib.database.fields.models import LongTextField, URLField, DateField
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.rows.handler import RowHandler
@ -172,6 +172,108 @@ def test_long_text_field_type(data_fixture):
assert len(LongTextField.objects.all()) == 1
@pytest.mark.django_db
def test_url_field_type(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
table_2 = data_fixture.create_database_table(user=user, database=table.database)
field = data_fixture.create_text_field(table=table, order=1, name='name')
field_handler = FieldHandler()
row_handler = RowHandler()
field_2 = field_handler.create_field(user=user, table=table, type_name='url',
name='url')
number = field_handler.create_field(user=user, table=table, type_name='number',
name='number')
assert len(URLField.objects.all()) == 1
model = table.get_model(attribute_names=True)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
'url': 'invalid_url'
}, model=model)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
'url': 'httpss'
}, model=model)
with pytest.raises(ValidationError):
row_handler.create_row(user=user, table=table, values={
'url': 'httpss'
}, model=model)
row_0 = row_handler.create_row(user=user, table=table, values={
'name': 'http://test.nl',
'url': 'https://baserow.io',
'number': 5
}, model=model)
row_1 = row_handler.create_row(user=user, table=table, values={
'name': 'http;//',
'url': 'http://localhost',
'number': 10
}, model=model)
row_2 = row_handler.create_row(user=user, table=table, values={
'name': 'bram@test.nl',
'url': 'http://www.baserow.io'
}, model=model)
row_3 = row_handler.create_row(user=user, table=table, values={
'name': 'NOT A URL',
'url': 'http://www.baserow.io/blog/building-a-database'
}, model=model)
row_4 = row_handler.create_row(user=user, table=table, values={
'name': 'ftps://www.complex.website.com?querystring=test&something=else',
'url': ''
}, model=model)
row_5 = row_handler.create_row(user=user, table=table, values={
'url': None,
}, model=model)
row_6 = row_handler.create_row(user=user, table=table, values={}, model=model)
# Convert to text field to a url field so we can check how the conversion of values
# went.
field_handler.update_field(user=user, field=field, new_type_name='url')
field_handler.update_field(user=user, field=number, new_type_name='url')
model = table.get_model(attribute_names=True)
rows = model.objects.all()
assert rows[0].name == 'http://test.nl'
assert rows[0].url == 'https://baserow.io'
assert rows[0].number == ''
assert rows[1].name == ''
assert rows[1].url == 'http://localhost'
assert rows[1].number == ''
assert rows[2].name == ''
assert rows[2].url == 'http://www.baserow.io'
assert rows[2].number == ''
assert rows[3].name == ''
assert rows[3].url == 'http://www.baserow.io/blog/building-a-database'
assert rows[3].number == ''
assert (
rows[4].name == 'ftps://www.complex.website.com?querystring=test&something=else'
)
assert rows[4].url == ''
assert rows[4].number == ''
assert rows[5].name == ''
assert rows[5].url == ''
assert rows[5].number == ''
assert rows[6].name == ''
assert rows[6].url == ''
assert rows[6].number == ''
field_handler.delete_field(user=user, field=field_2)
assert len(URLField.objects.all()) == 2
@pytest.mark.django_db
def test_date_field_type_prepare_value(data_fixture):
d = DateFieldType()

View file

@ -12,6 +12,7 @@
* Added filtering of rows per view.
* Fixed bug where the error message of the 'Select a table to link to' was not always
displayed.
* Added URL field.
## Released (2020-09-02)

View file

@ -34,3 +34,16 @@ export const slugify = (string) => {
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, '') // Trim - from end of text
}
export const isValidURL = (str) => {
const pattern = new RegExp(
'^((https?|ftps?):\\/\\/)?' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$',
'i'
) // fragment locator
return !!pattern.test(str)
}

View file

@ -0,0 +1,27 @@
<template>
<div class="control__elements">
<input
ref="input"
v-model="copy"
type="text"
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 URLField from '@baserow/modules/database/mixins/URLField'
export default {
mixins: [rowEditField, rowEditFieldInput, URLField],
}
</script>

View file

@ -0,0 +1,45 @@
<template>
<div
class="grid-view__cell"
:class="{
active: selected,
editing: editing,
invalid: editing && !isValid(),
}"
@contextmenu="stopContextIfEditing($event)"
>
<div v-show="!editing" class="grid-field-text">
<template v-if="!selected">{{ value }}</template>
<a v-if="selected" :href="value" target="_blank">{{ value }}</a>
</div>
<template v-if="editing">
<input
ref="input"
v-model="copy"
type="text"
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 URLField from '@baserow/modules/database/mixins/URLField'
export default {
mixins: [gridField, gridFieldInput, URLField],
methods: {
afterEdit() {
this.$nextTick(() => {
this.$refs.input.focus()
this.$refs.input.selectionStart = this.$refs.input.selectionEnd = 100000
})
},
},
}
</script>

View file

@ -1,5 +1,6 @@
import moment from 'moment'
import { isValidURL } from '@baserow/modules/core/utils/string'
import { Registerable } from '@baserow/modules/core/registry'
import FieldNumberSubForm from '@baserow/modules/database/components/field/FieldNumberSubForm'
@ -9,6 +10,7 @@ import FieldLinkRowSubForm from '@baserow/modules/database/components/field/Fiel
import GridViewFieldText from '@baserow/modules/database/components/view/grid/GridViewFieldText'
import GridViewFieldLongText from '@baserow/modules/database/components/view/grid/GridViewFieldLongText'
import GridViewFieldURL from '@baserow/modules/database/components/view/grid/GridViewFieldURL'
import GridViewFieldLinkRow from '@baserow/modules/database/components/view/grid/GridViewFieldLinkRow'
import GridViewFieldNumber from '@baserow/modules/database/components/view/grid/GridViewFieldNumber'
import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid/GridViewFieldBoolean'
@ -16,6 +18,7 @@ import GridViewFieldDate from '@baserow/modules/database/components/view/grid/Gr
import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText'
import RowEditFieldLongText from '@baserow/modules/database/components/row/RowEditFieldLongText'
import RowEditFieldURL from '@baserow/modules/database/components/row/RowEditFieldURL'
import RowEditFieldLinkRow from '@baserow/modules/database/components/row/RowEditFieldLinkRow'
import RowEditFieldNumber from '@baserow/modules/database/components/row/RowEditFieldNumber'
import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEditFieldBoolean'
@ -410,3 +413,30 @@ export class DateFieldType extends FieldType {
}
}
}
export class URLFieldType extends FieldType {
static getType() {
return 'url'
}
getIconClass() {
return 'link'
}
getName() {
return 'URL'
}
getGridViewFieldComponent() {
return GridViewFieldURL
}
getRowEditFieldComponent() {
return RowEditFieldURL
}
prepareValueForPaste(field, clipboardData) {
const value = clipboardData.getData('text')
return isValidURL(value) ? value : ''
}
}

View file

@ -0,0 +1,26 @@
import { isValidURL } from '@baserow/modules/core/utils/string'
/**
* This mixin contains some method overrides for validating and formatting the
* URL field. This mixin is used in both the GridViewFieldURL and
* RowEditFieldURL components.
*/
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 (!isValidURL(this.copy)) {
return 'Invalid URL'
}
return null
},
isValid() {
return this.getError() === null
},
},
}

View file

@ -3,6 +3,7 @@ import { GridViewType } from '@baserow/modules/database/viewTypes'
import {
TextFieldType,
LongTextFieldType,
URLFieldType,
LinkRowFieldType,
NumberFieldType,
BooleanFieldType,
@ -52,4 +53,5 @@ export default ({ store, app }) => {
app.$registry.register('field', new NumberFieldType())
app.$registry.register('field', new BooleanFieldType())
app.$registry.register('field', new DateFieldType())
app.$registry.register('field', new URLFieldType())
}

View file

@ -92,7 +92,7 @@ export class EqualViewFilterType extends ViewFilterType {
}
getCompatibleFieldTypes() {
return ['text', 'long_text', 'number']
return ['text', 'long_text', 'url', 'number']
}
matches(rowValue, filterValue) {
@ -120,7 +120,7 @@ export class NotEqualViewFilterType extends ViewFilterType {
}
getCompatibleFieldTypes() {
return ['text', 'long_text', 'number']
return ['text', 'long_text', 'url', 'number']
}
matches(rowValue, filterValue) {
@ -148,7 +148,7 @@ export class ContainsViewFilterType extends ViewFilterType {
}
getCompatibleFieldTypes() {
return ['text', 'long_text']
return ['text', 'long_text', 'url']
}
matches(rowValue, filterValue) {
@ -172,7 +172,7 @@ export class ContainsNotViewFilterType extends ViewFilterType {
}
getCompatibleFieldTypes() {
return ['text', 'long_text']
return ['text', 'long_text', 'url']
}
matches(rowValue, filterValue) {
@ -336,7 +336,7 @@ export class EmptyViewFilterType extends ViewFilterType {
}
getCompatibleFieldTypes() {
return ['text', 'long_text', 'number', 'date', 'boolean', 'link_row']
return ['text', 'long_text', 'url', 'number', 'date', 'boolean', 'link_row']
}
matches(rowValue, filterValue) {
@ -363,7 +363,7 @@ export class NotEmptyViewFilterType extends ViewFilterType {
}
getCompatibleFieldTypes() {
return ['text', 'long_text', 'number', 'date', 'boolean', 'link_row']
return ['text', 'long_text', 'url', 'number', 'date', 'boolean', 'link_row']
}
matches(rowValue, filterValue) {