1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-03-31 19:35:02 +00:00

Resolve "Add ratings field"

This commit is contained in:
Jrmi 2021-12-28 12:37:58 +00:00
parent 3e9fb65840
commit 92ace8884c
61 changed files with 3071 additions and 110 deletions

3
.gitignore vendored
View file

@ -112,7 +112,6 @@ out/
# vscode config files
.vscode
jsconfig.json
vetur.config.js
formula/out/
formula/out/

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
12

View file

@ -69,6 +69,7 @@ class DatabaseConfig(AppConfig):
LongTextFieldType,
URLFieldType,
NumberFieldType,
RatingFieldType,
BooleanFieldType,
DateFieldType,
LastModifiedFieldType,
@ -88,6 +89,7 @@ class DatabaseConfig(AppConfig):
field_type_registry.register(URLFieldType())
field_type_registry.register(EmailFieldType())
field_type_registry.register(NumberFieldType())
field_type_registry.register(RatingFieldType())
field_type_registry.register(BooleanFieldType())
field_type_registry.register(DateFieldType())
field_type_registry.register(LastModifiedFieldType())

View file

@ -35,6 +35,9 @@ def construct_all_possible_field_kwargs(
"number_negative": False,
},
],
"rating": [
{"name": "rating", "max_value": 5, "color": "blue", "style": "star"}
],
"boolean": [{"name": "boolean"}],
"date": [
{"name": "datetime_us", "date_include_time": True, "date_format": "US"},

View file

@ -18,6 +18,7 @@ from django.db.models import Case, When, Q, F, Func, Value, CharField
from django.db.models.expressions import RawSQL
from django.db.models.functions import Coalesce
from django.utils.timezone import make_aware
from pytz import timezone
from rest_framework import serializers
@ -85,6 +86,7 @@ from .models import (
LongTextField,
URLField,
NumberField,
RatingField,
BooleanField,
DateField,
LastModifiedField,
@ -436,6 +438,111 @@ class NumberFieldType(FieldType):
)
class RatingFieldType(FieldType):
type = "rating"
model_class = RatingField
allowed_fields = ["max_value", "color", "style"]
serializer_field_names = ["max_value", "color", "style"]
def prepare_value_for_db(self, instance, value):
if not value:
return 0
if value < 0:
raise ValidationError("Ensure this value is greater than or equal to 0.")
if value > instance.max_value:
raise ValidationError(
f"Ensure this value is less than or equal to {instance.max_value}."
)
return value
def get_serializer_field(self, instance, **kwargs):
return serializers.IntegerField(
**{
"required": False,
"allow_null": False,
"min_value": 0,
"default": 0,
"max_value": instance.max_value,
**kwargs,
}
)
def force_same_type_alter_column(self, from_field, to_field):
"""
Force field alter column hook to be called when chaging max_value.
"""
return to_field.max_value != from_field.max_value
def get_alter_column_prepare_new_value(self, connection, from_field, to_field):
"""
Prepare value for Rating field. Clamp between 0 and field max_value.
Also convert Null value to 0.
"""
if connection.vendor == "postgresql":
from_field_type = field_type_registry.get_by_model(from_field)
if from_field_type.type in ["number", "text", "rating"]:
# Convert and clamp values on field conversion
return (
f"p_in = least(greatest(round(p_in::numeric), 0)"
f", {to_field.max_value});"
)
if from_field_type.type == "boolean":
return """
IF p_in THEN
p_in = 1;
ELSE
p_in = 0;
END IF;
"""
return super().get_alter_column_prepare_new_value(
connection, from_field, to_field
)
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
"""
Prepare value from Rating field.
"""
if connection.vendor == "postgresql":
to_field_type = field_type_registry.get_by_model(to_field)
if to_field_type.type == "boolean":
return "p_in = least(p_in::numeric, 1);"
return super().get_alter_column_prepare_old_value(
connection, from_field, to_field
)
def get_model_field(self, instance, **kwargs):
return models.PositiveSmallIntegerField(
blank=False,
null=False,
default=0,
**kwargs,
)
def random_value(self, instance, fake, cache):
return fake.random_int(0, instance.max_value)
def contains_query(self, *args):
return contains_filter(*args)
def to_baserow_formula_type(self, field) -> BaserowFormulaType:
return BaserowFormulaNumberType(0)
def from_baserow_formula_type(
self, formula_type: BaserowFormulaNumberType
) -> "RatingField":
return RatingField()
class BooleanFieldType(FieldType):
type = "boolean"
model_class = BooleanField
@ -769,7 +876,7 @@ class CreatedOnLastModifiedBaseFieldType(DateFieldType):
before,
):
"""
If the field type has changed, we need to update the values from the from
If the field type has changed, we need to update the values from
the source_field_name column.
"""
@ -1705,7 +1812,7 @@ class SingleSelectFieldType(SelectOptionBaseFieldType):
variables,
)
return super().get_alter_column_prepare_old_value(
return super().get_alter_column_prepare_new_value(
connection, from_field, to_field
)

View file

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.functional import cached_property
from django.core.validators import MinValueValidator, MaxValueValidator
from baserow.contrib.database.fields.mixins import (
BaseDateMixin,
@ -39,6 +40,14 @@ NUMBER_DECIMAL_PLACES_CHOICES = [
(NUMBER_MAX_DECIMAL_PLACES, "1.00000"),
]
RATING_STYLE_CHOICES = [
("star", "Star"),
("heart", "Heart"),
("thumbs-up", "Thumbs-up"),
("flag", "Flags"),
("smile", "Smile"),
]
def get_default_field_content_type():
return ContentType.objects.get_for_model(Field)
@ -214,6 +223,47 @@ class NumberField(Field):
super(NumberField, self).save(*args, **kwargs)
class RatingField(Field):
max_value = models.PositiveSmallIntegerField(
default=5,
help_text="Maximum value the rating can take.",
validators=[MinValueValidator(1), MaxValueValidator(10)],
)
color = models.CharField(
max_length=50,
blank=False,
help_text="Color of the symbols.",
default="dark-orange",
)
style = models.CharField(
choices=RATING_STYLE_CHOICES,
default="star",
max_length=50,
blank=False,
help_text=(
"Rating style. Allowed values: "
f"{', '.join([value for (value, _) in RATING_STYLE_CHOICES])}."
),
)
def save(self, *args, **kwargs):
"""
Check if the max_value, color and style have a valid value.
"""
if not any(self.style in _tuple for _tuple in RATING_STYLE_CHOICES):
raise ValueError(f"{self.style} is not a valid choice.")
if not self.color:
raise ValueError(f"color should be defined.")
if self.max_value < 1:
raise ValueError("Ensure this value is greater than or equal to 1.")
if self.max_value > 10:
raise ValueError(f"Ensure this value is less than or equal to 10.")
super().save(*args, **kwargs)
class BooleanField(Field):
pass

View file

@ -0,0 +1,72 @@
# Generated by Django 3.2.6 on 2021-12-21 13:15
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("database", "0053_add_and_move_public_flags"),
]
operations = [
migrations.CreateModel(
name="RatingField",
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",
),
),
(
"max_value",
models.PositiveSmallIntegerField(
default=5,
help_text="Maximum value the rating can take.",
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(10),
],
),
),
(
"color",
models.CharField(
default="dark-orange",
help_text="Color of the symbols.",
max_length=50,
),
),
(
"style",
models.CharField(
choices=[
("star", "Star"),
("heart", "Heart"),
("thumbs-up", "Thumbs-up"),
("flag", "Flags"),
("smile", "Smile"),
],
default="star",
help_text=(
"Rating style. Allowed values: star, heart, "
"thumbs-up, flag, smile."
),
max_length=50,
),
),
],
options={
"abstract": False,
},
bases=("database.field",),
),
]

View file

@ -15,6 +15,7 @@ from .fields.models import (
Field,
TextField,
NumberField,
RatingField,
LongTextField,
BooleanField,
DateField,
@ -48,6 +49,7 @@ __all__ = [
"Field",
"TextField",
"NumberField",
"RatingField",
"LongTextField",
"BooleanField",
"DateField",

View file

@ -23,6 +23,7 @@ from baserow.contrib.database.fields.field_types import (
LongTextFieldType,
URLFieldType,
NumberFieldType,
RatingFieldType,
DateFieldType,
LastModifiedFieldType,
LinkRowFieldType,
@ -64,6 +65,7 @@ class EqualViewFilterType(ViewFilterType):
LongTextFieldType.type,
URLFieldType.type,
NumberFieldType.type,
RatingFieldType.type,
EmailFieldType.type,
PhoneNumberFieldType.type,
FormulaFieldType.compatible_with_formula_types(
@ -210,6 +212,7 @@ class HigherThanViewFilterType(ViewFilterType):
type = "higher_than"
compatible_field_types = [
NumberFieldType.type,
RatingFieldType.type,
FormulaFieldType.compatible_with_formula_types(
BaserowFormulaNumberType.type,
),
@ -246,6 +249,7 @@ class LowerThanViewFilterType(ViewFilterType):
type = "lower_than"
compatible_field_types = [
NumberFieldType.type,
RatingFieldType.type,
FormulaFieldType.compatible_with_formula_types(
BaserowFormulaNumberType.type,
),
@ -728,6 +732,7 @@ class EmptyViewFilterType(ViewFilterType):
LongTextFieldType.type,
URLFieldType.type,
NumberFieldType.type,
RatingFieldType.type,
BooleanFieldType.type,
DateFieldType.type,
LastModifiedFieldType.type,

View file

@ -5,6 +5,7 @@ from baserow.contrib.database.fields.models import (
TextField,
LongTextField,
NumberField,
RatingField,
BooleanField,
DateField,
LinkRowField,
@ -94,6 +95,23 @@ class FieldFixtures:
return field
def create_rating_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.name()
if "order" not in kwargs:
kwargs["order"] = 0
field = RatingField.objects.create(**kwargs)
if create_field:
self.create_model_field(kwargs["table"], field)
return field
def create_boolean_field(self, user=None, create_field=True, **kwargs):
if "table" not in kwargs:
kwargs["table"] = self.create_database_table(user=user)

View file

@ -90,6 +90,7 @@ def setup_interesting_test_table(data_fixture, user_kwargs=None):
"positive_int": 1,
"negative_decimal": Decimal("-1.2"),
"positive_decimal": Decimal("1.2"),
"rating": 3,
"boolean": "True",
"datetime_us": datetime,
"date_us": date,

View file

@ -279,6 +279,7 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
"phone_number": "+4412345678",
"positive_decimal": "1.2",
"positive_int": "1",
"rating": 3,
"single_select": {
"color": "red",
"id": SelectOption.objects.get(value="A").id,

View file

@ -13,6 +13,7 @@ from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.tokens.handler import TokenHandler
from baserow.test_utils.helpers import setup_interesting_test_table
@pytest.mark.django_db
@ -622,6 +623,25 @@ def test_create_row(api_client, data_fixture):
}
@pytest.mark.django_db
def test_create_empty_row_for_interesting_fields(api_client, data_fixture):
"""
Test a common case: create a row with empty values.
"""
table, user, row, _ = setup_interesting_test_table(data_fixture)
jwt_token = data_fixture.generate_token(user)
response = api_client.post(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
{},
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_200_OK
@pytest.mark.django_db
def test_get_row(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token()

View file

@ -220,16 +220,16 @@ def test_can_export_every_interesting_different_field_to_csv(
# noinspection HttpUrlsUsage
expected = (
"\ufeffid,text,long_text,url,email,negative_int,positive_int,"
"negative_decimal,positive_decimal,boolean,datetime_us,date_us,datetime_eu,"
"date_eu,last_modified_datetime_us,last_modified_date_us,"
"negative_decimal,positive_decimal,rating,boolean,datetime_us,date_us,"
"datetime_eu,date_eu,last_modified_datetime_us,last_modified_date_us,"
"last_modified_datetime_eu,last_modified_date_eu,created_on_datetime_us,"
"created_on_date_us,created_on_datetime_eu,created_on_date_eu,link_row,"
"decimal_link_row,file_link_row,file,single_select,multiple_select,"
"phone_number,formula,lookup\r\n"
"1,,,,,,,,,False,,,,,01/02/2021 13:00,01/02/2021,02/01/2021 13:00,02/01/2021,"
"decimal_link_row,file_link_row,file,single_select,"
"multiple_select,phone_number,formula,lookup\r\n"
"1,,,,,,,,,0,False,,,,,01/02/2021 13:00,01/02/2021,02/01/2021 13:00,02/01/2021,"
"01/02/2021 13:00,01/02/2021,02/01/2021 13:00,02/01/2021,,,,,,,,test FORMULA,"
"\r\n"
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,True,"
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,3,True,"
"02/01/2020 01:23,02/01/2020,01/02/2020 01:23,01/02/2020,"
"01/02/2021 13:00,01/02/2021,02/01/2021 13:00,02/01/2021,"
"01/02/2021 13:00,01/02/2021,02/01/2021 13:00,02/01/2021,"

View file

@ -541,6 +541,7 @@ def test_human_readable_values(data_fixture):
"phone_number": "",
"positive_decimal": "",
"positive_int": "",
"rating": "0",
"single_select": "",
"multiple_select": "",
"text": "",
@ -573,6 +574,7 @@ def test_human_readable_values(data_fixture):
"phone_number": "+4412345678",
"positive_decimal": "1.2",
"positive_int": "1",
"rating": "3",
"single_select": "A",
"multiple_select": "D, C, E",
"text": "text",

View file

@ -1340,11 +1340,11 @@ def test_conversion_date_to_multiple_select_field(data_fixture):
assert field_type.type == "multiple_select"
assert len(select_options) == 1
model = table.get_model()
model = table.get_model(attribute_names=True)
rows = list(model.objects.all().enhance_by_fields())
for index, field in enumerate(all_fields):
cell = getattr(rows[0], f"field_{field.id}").all()
cell = getattr(rows[0], field.model_attribute_name).all()
assert len(cell) == 1
assert cell[0].value == all_results[index]
@ -1354,11 +1354,20 @@ def test_conversion_date_to_multiple_select_field(data_fixture):
new_select_option = data_fixture.create_select_option(
field=date_field_eu, value="01/09/2021", color="green"
)
select_options = date_field_eu.select_options.all()
select_options = list(date_field_eu.select_options.all())
row_handler.create_row(
row = row_handler.create_row(
user=user,
table=table,
values={
f"field_{date_field_eu.id}": [select_options[0].id],
},
)
row_handler.update_row(
user=user,
table=table,
row_id=row.id,
values={
f"field_{date_field_eu.id}": [getattr(x, "id") for x in select_options],
},
@ -1377,18 +1386,14 @@ def test_conversion_date_to_multiple_select_field(data_fixture):
field=date_field_eu,
new_type_name="date",
date_format="EU",
name="date_field_eu",
)
model = table.get_model()
model = table.get_model(attribute_names=True)
rows = list(model.objects.all().enhance_by_fields())
field_cell_row_0 = getattr(rows[0], f"field_{date_field_eu.id}")
field_cell_row_1 = getattr(rows[1], f"field_{date_field_eu.id}")
field_cell_row_2 = getattr(rows[2], f"field_{date_field_eu.id}")
assert field_cell_row_0 == date(2021, 8, 31)
assert field_cell_row_1 == date(2021, 8, 31)
assert field_cell_row_2 == date(2021, 9, 1)
assert rows[0].datefieldeu == date(2021, 8, 31)
assert rows[1].datefieldeu == date(2021, 8, 31)
assert rows[2].datefieldeu == date(2021, 9, 1)
@pytest.mark.django_db

View file

@ -0,0 +1,297 @@
import pytest
from django.core.exceptions import ValidationError
from faker import Faker
from decimal import Decimal
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.fields.models import (
RatingField,
)
from baserow.contrib.database.rows.handler import RowHandler
@pytest.mark.django_db
def test_field_creation(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
field = data_fixture.create_text_field(table=table, order=1, name="name")
handler = FieldHandler()
field = handler.create_field(
user=user,
table=table,
type_name="rating",
name="rating",
max_value=4,
color="red",
style="flag",
)
assert len(RatingField.objects.all()) == 1
from_db = RatingField.objects.get(name="rating")
assert from_db.color == "red"
assert from_db.max_value == 4
assert from_db.style == "flag"
fake = Faker()
value = fake.random_int(1, 4)
model = table.get_model(attribute_names=True)
row = model.objects.create(rating=value, name="Test")
assert row.rating == value
assert row.name == "Test"
handler.delete_field(user=user, field=field)
assert len(RatingField.objects.all()) == 0
for invalid_value in [
{"max_value": 11},
{"max_value": 0},
{"max_value": -2},
{"style": "invalid"},
{"style": ""},
{"color": None},
{"color": ""},
]:
with pytest.raises(ValueError):
handler.create_field(
user=user,
table=table,
type_name="rating",
name="rating invalid",
**invalid_value
)
@pytest.mark.django_db
def test_row_creation(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)
data_fixture.create_text_field(table=table, order=1, name="name")
field_handler = FieldHandler()
row_handler = RowHandler()
field_handler.create_field(
user=user, table=table, type_name="rating", name="rating"
)
assert len(RatingField.objects.all()) == 1
model = table.get_model(attribute_names=True)
row1 = row_handler.create_row(
user=user, table=table, values={"rating": 3}, model=model
)
row_handler.create_row(user=user, table=table, values={"rating": 0}, model=model)
row_handler.create_row(user=user, table=table, values={"rating": None}, model=model)
row_handler.create_row(user=user, table=table, values={}, model=model)
assert [(f.id, f.rating) for f in model.objects.all()] == [
(1, 3),
(2, 0),
(3, 0),
(4, 0),
]
row_handler.update_row(
user_field_names=True,
user=user,
row_id=row1.id,
table=table,
values={"rating": 1},
)
assert [(f.id, f.rating) for f in model.objects.all()] == [
(1, 1),
(2, 0),
(3, 0),
(4, 0),
]
for invalid_value in [-1, 6]:
with pytest.raises(ValidationError):
row_handler.create_row(
user=user, table=table, values={"rating": invalid_value}, model=model
)
row_handler.update_row(
user=user,
row_id=row1.id,
table=table,
values={"rating": invalid_value},
)
@pytest.mark.django_db
def test_rating_field_modification(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)
text_field = data_fixture.create_text_field(table=table, order=1, name="text")
field_handler = FieldHandler()
row_handler = RowHandler()
rating_field = field_handler.create_field(
user=user, table=table, type_name="rating", name="Rating"
)
integer_field = data_fixture.create_number_field(
table=table,
name="integer",
number_type="INTEGER",
number_negative=True,
)
decimal_field = data_fixture.create_number_field(
table=table,
name="decimal",
number_type="DECIMAL",
number_negative=True,
)
boolean_field = data_fixture.create_boolean_field(table=table, name="boolean")
model = table.get_model(attribute_names=True)
row_handler.create_row(
user=user,
table=table,
values={
"text": "5",
"rating": 5,
"integer": 5,
"decimal": 4.5,
"boolean": True,
},
model=model,
)
row_handler.create_row(
user=user,
table=table,
values={
"text": "3",
"rating": 4,
"integer": 3,
"decimal": 2.5,
"boolean": False,
},
model=model,
)
row3 = row_handler.create_row(
user=user,
table=table,
values={"text": "1", "rating": 3, "integer": 1, "decimal": 1.3},
model=model,
)
row_handler.create_row(
user=user,
table=table,
values={"text": "1.5", "rating": 2, "integer": -1, "decimal": -1.2},
model=model,
)
row_handler.create_row(
user=user,
table=table,
values={"text": "invalid", "rating": 1, "integer": -5, "decimal": -7},
model=model,
)
row_handler.create_row(
user=user,
table=table,
values={"text": "0", "rating": 0, "integer": 0, "decimal": 0},
model=model,
)
row_handler.create_row(
user=user,
table=table,
values={
"text": None,
"rating": None,
"integer": None,
"number": None,
},
model=model,
)
# Convert text field to rating
field_handler.update_field(
user=user, field=text_field, new_type_name="rating", max_value=3
)
# Change max_value
field_handler.update_field(user=user, field=rating_field, max_value=3)
# Change field type from number -> rating
field_handler.update_field(
user=user,
field=integer_field,
new_type_name="rating",
max_value=3,
)
field_handler.update_field(
user=user,
field=decimal_field,
new_type_name="rating",
max_value=3,
)
field_handler.update_field(
user=user,
field=boolean_field,
new_type_name="rating",
max_value=3,
)
# Check value clamping on max_value modification
assert [
(f.id, f.text, f.rating, f.integer, f.decimal, f.boolean)
for f in model.objects.all()
] == [
(1, 3, 3, 3, 3, 1),
(2, 3, 3, 3, 3, 0),
(3, 1, 3, 1, 1, 0),
(4, 2, 2, 0, 0, 0),
(5, 0, 1, 0, 0, 0),
(6, 0, 0, 0, 0, 0),
(7, 0, 0, 0, 0, 0),
]
# Change boolean field to test conversion back with value != [0,1]
row_handler.update_row(
user=user,
row_id=row3.id,
table=table,
user_field_names=True,
values={"boolean": 3},
)
# Convert back field to original type
field_handler.update_field(user=user, field=text_field, new_type_name="text")
field_handler.update_field(
user=user,
field=integer_field,
new_type_name="number",
number_type="INTEGER",
number_negative=True,
)
field_handler.update_field(
user=user,
field=decimal_field,
new_type_name="number",
number_type="DECIMAL",
number_negative=True,
number_decimal_places=2,
)
field_handler.update_field(
user=user,
field=boolean_field,
new_type_name="boolean",
)
assert [
(f.id, f.text, f.integer, f.decimal, f.boolean) for f in model.objects.all()
] == [
(1, "3", Decimal("3"), Decimal("3.00"), True),
(2, "3", Decimal("3"), Decimal("3.00"), False),
(3, "1", Decimal("1"), Decimal("1.00"), True),
(4, "2", Decimal("0"), Decimal("0.00"), False),
(5, "0", Decimal("0"), Decimal("0.00"), False),
(6, "0", Decimal("0"), Decimal("0.00"), False),
(7, "0", Decimal("0"), Decimal("0.00"), False),
]

View file

@ -2,6 +2,7 @@
## Unreleased
* Added rating field type.
* Fix deleted options that appear in the command line JSON file export.
* Fix subtracting date intervals from dates in formulas in some situations not working.
* Added day of month filter to date field.
@ -24,12 +25,12 @@
* Fixed a bug where the frontend would fail hard if a table with no views was accessed.
* Tables can now be opened in new browser tabs.
* **Breaking Change**: Baserow's `docker-compose.yml` now allows setting the MEDIA_URL
* **Breaking Change**: Baserow's `docker-compose.yml` now allows setting the MEDIA_URL
env variable. If using MEDIA_PORT you now need to set MEDIA_URL also.
* **Breaking Change**: Baserow's `docker-compose.yml` container names have changed to
no longer be hardcoded to prevent naming clashes.
* Added a licensing system for the premium version.
* Fixed bug where it was possible to create duplicate trash entries.
* Fixed bug where it was possible to create duplicate trash entries.
* Fixed propType validation error when converting from a date field to a boolean field.
* Deprecate internal formula field function field_by_id.
* Made it possible to change user information.

View file

@ -47,6 +47,7 @@ def test_can_export_every_interesting_different_field_to_json(
"positive_int": "",
"negative_decimal": "",
"positive_decimal": "",
"rating": 0,
"boolean": false,
"datetime_us": "",
"date_us": "",
@ -80,6 +81,7 @@ def test_can_export_every_interesting_different_field_to_json(
"positive_int": 1,
"negative_decimal": "-1.2",
"positive_decimal": "1.2",
"rating": 3,
"boolean": true,
"datetime_us": "02/01/2020 01:23",
"date_us": "02/01/2020",
@ -208,6 +210,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<positive-int/>
<negative-decimal/>
<positive-decimal/>
<rating>0</rating>
<boolean>false</boolean>
<datetime-us/>
<date-us/>
@ -241,6 +244,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<positive-int>1</positive-int>
<negative-decimal>-1.2</negative-decimal>
<positive-decimal>1.2</positive-decimal>
<rating>3</rating>
<boolean>true</boolean>
<datetime-us>02/01/2020 01:23</datetime-us>
<date-us>02/01/2020</date-us>

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@baserow_premium/*": [
"./*"
]
}
},
"exclude": [
"node_modules",
".nuxt"
]
}

View file

@ -45,7 +45,7 @@
<script>
import { required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import { colors } from '@baserow/modules/core/utils/colors'
import { randomColor } from '@baserow/modules/core/utils/colors'
import ColorSelectContext from '@baserow/modules/core/components/ColorSelectContext'
export default {
@ -56,7 +56,7 @@ export default {
return {
allowedValues: ['color', 'value'],
values: {
color: colors[Math.floor(Math.random() * colors.length)],
color: randomColor(),
value: '',
},
}

View file

@ -30,5 +30,6 @@ module.exports = {
},
],
'import/order': 'off',
'vue/html-self-closing': 'off',
},
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@baserow/*": [
"./*"
]
}
},
"exclude": [
"node_modules",
".nuxt"
]
}

View file

@ -3,6 +3,7 @@ export default {
yes: 'yes',
no: 'no',
wrong: 'Something went wrong',
none: 'None',
},
action: {
upload: 'Upload',
@ -62,6 +63,7 @@ export default {
longText: 'Long text',
linkToTable: 'Link to table',
number: 'Number',
rating: 'Rating',
boolean: 'Boolean',
date: 'Date',
lastModified: 'Last modified',
@ -95,6 +97,7 @@ export default {
decimal: 'Accepts a decimal with {places} decimal places after the dot.',
decimalPositive:
'Accepts a positive decimal with {places} decimal places after the dot.',
rating: 'Accepts a number.',
boolean: 'Accepts a boolean.',
date: 'Accepts a date time in ISO format.',
dateTime: 'Accepts a date in ISO format.',

View file

@ -3,6 +3,7 @@ export default {
yes: 'oui',
no: 'non',
wrong: 'Une erreur est survenue',
none: 'Aucun(e)',
},
action: {
upload: 'Envoyer',
@ -62,6 +63,7 @@ export default {
longText: 'Texte long',
linkToTable: 'Lien vers une table',
number: 'Nombre',
rating: 'Classement',
boolean: 'Booléen',
date: 'Date',
lastModified: 'Dernière modification',
@ -95,6 +97,7 @@ export default {
numberPositive: 'Accepte un entier positive.',
decimal: 'Accepte un nombre décimal.',
decimalPositive: 'Accepte un nombre décimal positif.',
rating: 'Accepte un nombre entier',
boolean: 'Accepte une valeur booléenne.',
date: 'Accepte une date au format ISO.',
dateTime: 'Accepte une date/heure au format ISO.',

View file

@ -14,8 +14,10 @@
@import 'select';
@import 'dropdown';
@import 'tooltip';
@import 'rating';
@import 'fields/boolean';
@import 'fields/number';
@import 'fields/rating';
@import 'fields/long_text';
@import 'fields/date';
@import 'fields/link_row';
@ -27,8 +29,9 @@
@import 'views/grid';
@import 'views/grid/text';
@import 'views/grid/long_text';
@import 'views/grid/boolean';
@import 'views/grid/number';
@import 'views/grid/rating';
@import 'views/grid/boolean';
@import 'views/grid/date';
@import 'views/grid/link_row';
@import 'views/grid/file';

View file

@ -1,24 +1,22 @@
.color-select-context {
width: 212px;
}
.color-select-context__colors {
display: flex;
flex-wrap: wrap;
padding: 6px;
flex-direction: column;
padding: 12px;
gap: 12px;
}
.color-select-context__row {
display: flex;
flex-direction: row;
gap: 12px;
}
.color-select-context__color {
position: relative;
flex: 0 0 28px;
height: 28px;
margin: 6px;
width: 28px;
border-radius: 3px;
&:nth-child(5n+5) {
margin-right: 0;
}
&:not(.active):hover {
box-shadow: 0 0 2px rgba(0, 0, 0, 0.08);
}

View file

@ -57,3 +57,63 @@
.background-color--dark-red {
background-color: $color-error-300;
}
.color--light-blue {
color: $color-primary-100;
}
.color--light-gray {
color: $color-neutral-100;
}
.color--light-green {
color: $color-success-100;
}
.color--light-orange {
color: $color-warning-100;
}
.color--light-red {
color: $color-error-100;
}
.color--blue {
color: $color-primary-200;
}
.color--gray {
color: $color-neutral-200;
}
.color--green {
color: $color-success-200;
}
.color--orange {
color: $color-warning-200;
}
.color--red {
color: $color-error-200;
}
.color--dark-blue {
color: $color-primary-300;
}
.color--dark-gray {
color: $color-neutral-300;
}
.color--dark-green {
color: $color-success-300;
}
.color--dark-orange {
color: $color-warning-300;
}
.color--dark-red {
color: $color-error-300;
}

View file

@ -0,0 +1,26 @@
.rating-field-form {
display: flex;
justify-content: space-between;
}
.rating-field-form__dropdown-style .select__item-name {
color: $color-neutral-600;
}
.rating-field__color {
padding: 10px;
text-align: center;
color: $color-primary-900;
border-radius: 3px;
font-size: 14px;
display: block;
}
.field-rating {
@extend %ellipsis;
@include fixed-height(32px, 13px);
padding-left: 5px;
user-select: none;
}

View file

@ -104,6 +104,12 @@
width: 130px;
}
.filters__value-rating {
border: solid 1px $color-neutral-400;
border-radius: 3px;
padding: 6px 12px;
}
.filters__value-link-row {
@extend %ellipsis;

View file

@ -0,0 +1,51 @@
.rating__star {
font-size: 18px;
padding-left: 2px;
pointer-events: initial;
}
.rating {
@extend %ellipsis;
display: block;
min-height: 18px;
user-select: none;
pointer-events: none;
& > .rating__star:first-child {
padding-left: 0;
}
&.editing {
& > .rating__star {
color: $color-neutral-200;
cursor: pointer;
}
// If we hover the rating, all stars should be colored and semi-transparent
// by default
&:hover > .rating__star {
color: inherit;
opacity: 0.6;
}
// but selected stars should have full opacity.
& > .rating__star.rating__star--selected {
color: inherit;
opacity: 1;
}
// stars after the hovered one should be grey and have full opacity
& > .rating__star:hover ~ .rating__star {
color: $color-neutral-200;
opacity: 1;
}
// selected star after the hovered star should be colored and be
// semi-transparent
& > .rating__star:hover ~ .rating__star.rating__star--selected {
color: inherit;
opacity: 0.6;
}
}
}

View file

@ -0,0 +1,8 @@
.grid-field-rating {
@extend %ellipsis;
@include fixed-height(32px, 13px);
padding-left: 5px;
user-select: none;
}

View file

@ -1,39 +1,51 @@
<template>
<Context ref="context" class="color-select-context">
<div class="color-select-context__colors">
<a
v-for="(color, index) in colors"
:key="color + '-' + index"
class="color-select-context__color"
:class="
'background-color--' +
color +
' ' +
(color === active ? 'active' : '')
"
@click="select(color)"
></a>
<div
v-for="(colorRow, rowIndex) in colors"
:key="`color-row-${rowIndex}`"
class="color-select-context__row"
>
<a
v-for="(color, index) in colorRow"
:key="`color-${index}`"
class="color-select-context__color"
:class="[
`background-color--${color}`,
color === active ? 'active' : '',
]"
@click="select(color)"
></a>
</div>
</div>
</Context>
</template>
<script>
import context from '@baserow/modules/core/mixins/context'
import { colors } from '@baserow/modules/core/utils/colors'
import { colors as colorList } from '@baserow/modules/core/utils/colors'
const defaultColors = [
colorList.slice(0, 5),
colorList.slice(5, 10),
colorList.slice(10, 15),
]
export default {
name: 'ColorSelectContext',
mixins: [context],
props: {
colors: {
type: Array,
default: () => defaultColors,
required: false,
},
},
data() {
return {
active: '',
}
},
computed: {
colors() {
return colors
},
},
methods: {
setActive(color) {
this.active = color

View file

@ -8,12 +8,14 @@
>
<a v-if="showInput" class="dropdown__selected" @click="show()">
<template v-if="hasValue()">
<i
v-if="selectedIcon"
class="dropdown__selected-icon fas"
:class="'fa-' + selectedIcon"
></i>
{{ selectedName }}
<slot name="value">
<i
v-if="selectedIcon"
class="dropdown__selected-icon fas"
:class="'fa-' + selectedIcon"
/>
{{ selectedName }}
</slot>
</template>
<template v-else>{{ $t('action.makeChoice') }}</template>
<i class="dropdown__toggle-icon fas fa-caret-down"></i>

View file

@ -14,12 +14,14 @@
@mousemove="hover(value, disabled)"
>
<div class="select__item-name">
<i
v-if="icon"
class="select__item-icon fas fa-fw"
:class="'fa-' + icon"
></i>
{{ name }}
<slot>
<i
v-if="icon"
class="select__item-icon fas fa-fw"
:class="'fa-' + icon"
/>
{{ name }}
</slot>
</div>
<div v-if="description !== null" class="select__item-description">
{{ description }}

View file

@ -47,6 +47,9 @@ export default {
return this.isVisible(query)
},
isVisible(query) {
if (!query) {
return true
}
const regex = new RegExp('(' + escapeRegExp(query) + ')', 'i')
return this.name.match(regex)
},

View file

@ -15,3 +15,7 @@ export const colors = [
'dark-red',
'dark-gray',
]
export const randomColor = () => {
return colors[Math.floor(Math.random() * colors.length)]
}

View file

@ -0,0 +1,53 @@
<template functional>
<div
class="rating"
:class="[
data.staticClass,
`color--${props.color}`,
props.readOnly ? '' : 'editing',
]"
>
<i
v-for="index in props.readOnly ? props.value : props.maxValue"
:key="index"
class="fas rating__star"
:class="{
[`fa-${props.ratingStyle}`]: true,
'rating__star--selected': index <= props.value,
}"
@click="
!props.readOnly &&
listeners['update'] &&
listeners['update'](index === props.value ? 0 : index)
"
/>
</div>
</template>
<script>
export default {
name: 'Rating',
props: {
readOnly: {
default: false,
type: Boolean,
},
value: {
required: true,
validator: () => true,
},
maxValue: {
required: true,
type: Number,
},
ratingStyle: {
default: 'star',
type: String,
},
color: {
default: 'dark-orange',
type: String,
},
},
}
</script>

View file

@ -0,0 +1,20 @@
<template functional>
<component
:is="$options.components.Rating"
:read-only="true"
:rating-style="props.field.style"
:color="props.field.color"
:value="props.value"
:max-value="props.field.max_value"
class="card-rating"
></component>
</template>
<script>
import Rating from '@baserow/modules/database/components/Rating'
export default {
height: 18,
components: { Rating },
}
</script>

View file

@ -0,0 +1,134 @@
<template>
<div class="rating-field-form">
<div class="control">
<label class="control__label control__label--small">{{
$t('fieldRatingSubForm.color')
}}</label>
<div class="control__elements">
<a
:ref="'color-select'"
:class="'rating-field__color' + ' background-color--' + values.color"
@click="openColor()"
>
<i class="fas fa-caret-down"></i>
</a>
</div>
</div>
<div class="control">
<label class="control__label control__label--small">{{
$t('fieldRatingSubForm.style')
}}</label>
<div class="control__elements">
<Dropdown
v-model="values.style"
class="dropdown--floating rating-field-form__dropdown-style"
:class="{ 'dropdown--error': $v.values.style.$error }"
:show-search="false"
@hide="$v.values.style.$touch()"
>
<DropdownItem
v-for="style in styles"
:key="style"
name=""
:value="style"
:icon="style"
/>
</Dropdown>
</div>
</div>
<div class="control">
<label class="control__label control__label--small">{{
$t('fieldRatingSubForm.maxValue')
}}</label>
<div class="control__elements">
<Dropdown
v-model="values.max_value"
class="dropdown--floating"
:class="{ 'dropdown--error': $v.values.max_value.$error }"
:show-search="false"
@hide="$v.values.max_value.$touch()"
>
<DropdownItem
v-for="index in 10"
:key="index"
:name="`${index}`"
:value="index"
></DropdownItem>
</Dropdown>
</div>
</div>
<ColorSelectContext
ref="colorContext"
:colors="colors"
@selected="updateColor($event)"
></ColorSelectContext>
</div>
</template>
<script>
import { required } from 'vuelidate/lib/validators'
import form from '@baserow/modules/core/mixins/form'
import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
import ColorSelectContext from '@baserow/modules/core/components/ColorSelectContext'
const colors = [['dark-blue', 'dark-green', 'dark-orange', 'dark-red']]
export default {
name: 'FieldRatingSubForm',
components: { ColorSelectContext },
mixins: [form, fieldSubForm],
data() {
return {
allowedValues: ['max_value', 'color', 'style'],
values: {
max_value: 5,
color: 'dark-orange',
style: 'star',
},
colors,
styles: ['star', 'heart', 'thumbs-up', 'flag', 'smile'],
}
},
methods: {
openColor() {
this.$refs.colorContext.setActive(this.values.color)
this.$refs.colorContext.toggle(
this.$refs['color-select'],
'bottom',
'left',
4
)
},
updateColor(color) {
this.values.color = color
},
},
validations: {
values: {
max_value: { required },
color: { required },
style: { required },
},
},
}
</script>
<i18n>
{
"en": {
"fieldRatingSubForm": {
"maxValue": "Max",
"color": "Color",
"style": "Style"
}
},
"fr": {
"fieldRatingSubForm": {
"maxValue": "Max",
"color": "Couleur",
"style": "Style"
}
}
}
</i18n>

View file

@ -47,7 +47,7 @@
import { required } from 'vuelidate/lib/validators'
import ColorSelectContext from '@baserow/modules/core/components/ColorSelectContext'
import { colors } from '@baserow/modules/core/utils/colors'
import { randomColor } from '@baserow/modules/core/utils/colors'
export default {
name: 'FieldSelectOptions',
@ -72,7 +72,7 @@ export default {
add() {
this.value.push({
value: '',
color: colors[Math.floor(Math.random() * colors.length)],
color: randomColor(),
})
this.$emit('input', this.value)
},

View file

@ -0,0 +1,30 @@
<template>
<div class="control__elements">
<div class="field-rating">
<Rating
:rating-style="field.style"
:color="field.color"
:value="value"
:max-value="field.max_value"
:read-only="readOnly"
@update="update"
/>
</div>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import Rating from '@baserow/modules/database/components/Rating'
export default {
components: { Rating },
mixins: [rowEditField],
methods: {
update(newValue) {
this.$emit('update', newValue, this.value)
},
},
}
</script>

View file

@ -15,23 +15,28 @@
})
}}</span>
</a>
<ViewFilterContext
<Context
ref="context"
:view="view"
:fields="fields"
:primary="primary"
:read-only="readOnly"
@changed="$emit('changed')"
></ViewFilterContext>
class="filters"
:class="{ 'context--loading-overlay': view._.loading }"
>
<ViewFilterForm
:primary="primary"
:fields="fields"
:view="view"
:read-only="readOnly"
@changed="$emit('changed')"
/>
</Context>
</div>
</template>
<script>
import ViewFilterContext from '@baserow/modules/database/components/view/ViewFilterContext'
import ViewFilterForm from '@baserow/modules/database/components/view/ViewFilterForm'
export default {
name: 'ViewFilter',
components: { ViewFilterContext },
components: { ViewFilterForm },
props: {
primary: {
type: Object,

View file

@ -1,9 +1,5 @@
<template>
<Context
ref="context"
class="filters"
:class="{ 'context--loading-overlay': view._.loading }"
>
<div>
<div v-show="view.filters.length === 0">
<div class="filters__none">
<div class="filters__none-title">
@ -104,7 +100,7 @@
</div>
<div class="filters__value">
<component
:is="getInputComponent(filter.type)"
:is="getInputComponent(filter.type, filter.field)"
:ref="'filter-' + filter.id + '-value'"
:filter="filter"
:fields="fields"
@ -116,9 +112,8 @@
</div>
<div v-if="!readOnly" class="filters_footer">
<a class="filters__add" @click.prevent="addFilter()">
<i class="fas fa-plus"></i>
{{ $t('viewFilterContext.addFilter') }}
</a>
<i class="fas fa-plus"></i>{{ $t('viewFilterContext.addFilter') }}</a
>
<div v-if="view.filters.length > 0">
<SwitchInput
:value="view.filters_disabled"
@ -127,16 +122,14 @@
>
</div>
</div>
</Context>
</div>
</template>
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import context from '@baserow/modules/core/mixins/context'
export default {
name: 'ViewFilterContext',
mixins: [context],
name: 'ViewFilterForm',
props: {
primary: {
type: Object,
@ -308,8 +301,9 @@ export default {
* Returns the input component related to the filter type. This component is
* responsible for updating the filter value.
*/
getInputComponent(type) {
return this.$registry.get('viewFilter', type).getInputComponent()
getInputComponent(type, fieldId) {
const field = this.fields.find(({ id }) => id === fieldId)
return this.$registry.get('viewFilter', type).getInputComponent(field)
},
},
}

View file

@ -0,0 +1,26 @@
<template>
<div class="filters__value-rating">
<Rating
:rating-style="field.style"
:color="field.color"
:value="copy"
:max-value="field.max_value"
@update="delayedUpdate($event, true)"
/>
</div>
</template>
<script>
import filterTypeInput from '@baserow/modules/database/mixins/filterTypeInput'
import Rating from '@baserow/modules/database/components/Rating'
export default {
name: 'ViewFilterTypeText',
components: { Rating },
mixins: [filterTypeInput],
created() {
// Value from server is always a string, we need to parse it.
this.copy = parseInt(this.filter.value, 10)
},
}
</script>

View file

@ -0,0 +1,29 @@
<template functional>
<div
class="grid-view__cell"
:class="{
...(data.staticClass && {
[data.staticClass]: true,
}),
}"
>
<div class="grid-field-rating">
<component
:is="$options.components.Rating"
:read-only="true"
:rating-style="props.field.style"
:color="props.field.color"
:value="props.value"
:max-value="props.field.max_value"
></component>
</div>
</div>
</template>
<script>
import Rating from '@baserow/modules/database/components/Rating'
export default {
components: { Rating },
}
</script>

View file

@ -0,0 +1,67 @@
<template>
<div class="grid-view__cell active">
<div class="grid-field-rating">
<Rating
:read-only="readOnly"
:rating-style="field.style"
:color="field.color"
:value="value"
:max-value="field.max_value"
@update="update"
/>
</div>
</div>
</template>
<script>
import gridField from '@baserow/modules/database/mixins/gridField'
import Rating from '@baserow/modules/database/components/Rating'
export default {
components: { Rating },
mixins: [gridField],
mounted() {
window.addEventListener('keyup', this.keyup)
},
beforeDestroy() {
window.removeEventListener('keyup', this.keyup)
},
methods: {
update(newValue) {
this.$emit('update', newValue, this.value)
},
keyup(event) {
// Allow keyboard modification
const { key } = event
let newValue = this.value
if ('0123456789'.includes(key)) {
// Transform digit in value
newValue = parseInt(key, 10)
} else {
// +, > to increase -, < to decrease
switch (key) {
case '+':
case '>':
newValue = this.value + 1
break
case '-':
case '<':
newValue = this.value - 1
break
}
}
if (newValue !== this.value) {
// Clamp value
if (newValue > this.field.max_value) {
newValue = this.field.max_value
}
if (newValue < 0) {
newValue = 0
}
this.$emit('update', newValue, this.value)
}
},
},
}
</script>

View file

@ -10,6 +10,7 @@ import {
import { Registerable } from '@baserow/modules/core/registry'
import FieldNumberSubForm from '@baserow/modules/database/components/field/FieldNumberSubForm'
import FieldRatingSubForm from '@baserow/modules/database/components/field/FieldRatingSubForm'
import FieldTextSubForm from '@baserow/modules/database/components/field/FieldTextSubForm'
import FieldDateSubForm from '@baserow/modules/database/components/field/FieldDateSubForm'
import FieldCreatedOnLastModifiedSubForm from '@baserow/modules/database/components/field/FieldCreatedOnLastModifiedSubForm'
@ -22,6 +23,7 @@ import GridViewFieldURL from '@baserow/modules/database/components/view/grid/fie
import GridViewFieldEmail from '@baserow/modules/database/components/view/grid/fields/GridViewFieldEmail'
import GridViewFieldLinkRow from '@baserow/modules/database/components/view/grid/fields/GridViewFieldLinkRow'
import GridViewFieldNumber from '@baserow/modules/database/components/view/grid/fields/GridViewFieldNumber'
import GridViewFieldRating from '@baserow/modules/database/components/view/grid/fields/GridViewFieldRating'
import GridViewFieldBoolean from '@baserow/modules/database/components/view/grid/fields/GridViewFieldBoolean'
import GridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/GridViewFieldDate'
import GridViewFieldDateReadOnly from '@baserow/modules/database/components/view/grid/fields/GridViewFieldDateReadOnly'
@ -34,6 +36,7 @@ import FunctionalGridViewFieldText from '@baserow/modules/database/components/vi
import FunctionalGridViewFieldLongText from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldLongText'
import FunctionalGridViewFieldLinkRow from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldLinkRow'
import FunctionalGridViewFieldNumber from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldNumber'
import FunctionalGridViewFieldRating from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldRating'
import FunctionalGridViewFieldBoolean from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldBoolean'
import FunctionalGridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldDate'
import FunctionalGridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldFile'
@ -48,6 +51,7 @@ import RowEditFieldURL from '@baserow/modules/database/components/row/RowEditFie
import RowEditFieldEmail from '@baserow/modules/database/components/row/RowEditFieldEmail'
import RowEditFieldLinkRow from '@baserow/modules/database/components/row/RowEditFieldLinkRow'
import RowEditFieldNumber from '@baserow/modules/database/components/row/RowEditFieldNumber'
import RowEditFieldRating from '@baserow/modules/database/components/row/RowEditFieldRating'
import RowEditFieldBoolean from '@baserow/modules/database/components/row/RowEditFieldBoolean'
import RowEditFieldDate from '@baserow/modules/database/components/row/RowEditFieldDate'
import RowEditFieldDateReadOnly from '@baserow/modules/database/components/row/RowEditFieldDateReadOnly'
@ -64,6 +68,7 @@ import RowCardFieldFormula from '@baserow/modules/database/components/card/RowCa
import RowCardFieldLinkRow from '@baserow/modules/database/components/card/RowCardFieldLinkRow'
import RowCardFieldMultipleSelect from '@baserow/modules/database/components/card/RowCardFieldMultipleSelect'
import RowCardFieldNumber from '@baserow/modules/database/components/card/RowCardFieldNumber'
import RowCardFieldRating from '@baserow/modules/database/components/card/RowCardFieldRating'
import RowCardFieldPhoneNumber from '@baserow/modules/database/components/card/RowCardFieldPhoneNumber'
import RowCardFieldSingleSelect from '@baserow/modules/database/components/card/RowCardFieldSingleSelect'
import RowCardFieldText from '@baserow/modules/database/components/card/RowCardFieldText'
@ -855,7 +860,7 @@ export class NumberFieldType extends FieldType {
/**
* Formats the value based on the field's settings. The number will be rounded
* if to much decimal places are provided and if negative numbers aren't allowed
* if too much decimal places are provided and if negative numbers aren't allowed
* they will be set to 0.
*/
static formatNumber(field, value) {
@ -905,6 +910,114 @@ export class NumberFieldType extends FieldType {
}
}
export class RatingFieldType extends FieldType {
static getMaxNumberLength() {
return 2
}
static getType() {
return 'rating'
}
getIconClass() {
return 'star'
}
getName() {
const { i18n } = this.app
return i18n.t('fieldType.rating')
}
getFormComponent() {
return FieldRatingSubForm
}
getGridViewFieldComponent() {
return GridViewFieldRating
}
getFunctionalGridViewFieldComponent() {
return FunctionalGridViewFieldRating
}
getRowEditFieldComponent() {
return RowEditFieldRating
}
getCardComponent() {
return RowCardFieldRating
}
getSortIndicator() {
return ['text', '1', '9']
}
getEmptyValue(field) {
return 0
}
getSort(name, order) {
return (a, b) => {
if (a[name] === b[name]) {
return 0
}
const numberA = a[name]
const numberB = b[name]
return order === 'ASC'
? numberA < numberB
? -1
: 1
: numberB < numberA
? -1
: 1
}
}
/**
* First checks if the value is numeric, if that is the case, the number is going
* to be formatted.
*/
prepareValueForPaste(field, clipboardData) {
const pastedValue = clipboardData.getData('text')
const value = parseInt(pastedValue, 10)
if (isNaN(value) || !isFinite(value)) {
return
}
// Clamp the value
if (value < 0) {
return 0
}
if (value > field.max_value) {
return field.max_value
}
return value
}
getDocsDataType(field) {
return 'number'
}
getDocsDescription(field) {
return this.app.i18n.t(`fieldDocs.rating`)
}
getDocsRequestExample(field) {
return 3
}
getContainsFilterFunction() {
return genericContainsFilter
}
canBeReferencedByFormulaField() {
return true
}
}
export class BooleanFieldType extends FieldType {
static getType() {
return 'boolean'

View file

@ -1,7 +1,7 @@
import { notifyIf } from '@baserow/modules/core/utils/error'
import { clone } from '@baserow/modules/core/utils/object'
import { isPrintableUnicodeCharacterKeyPress } from '@baserow/modules/core/utils/events'
import { colors } from '@baserow/modules/core/utils/colors'
import { randomColor } from '@baserow/modules/core/utils/colors'
import FieldSelectOptionsDropdown from '@baserow/modules/database/components/field/FieldSelectOptionsDropdown'
export default {
@ -9,14 +9,14 @@ export default {
methods: {
/**
* Adds a new select option to the field and then updates the field. This method is
* called from the dropdown, the user can create a new optionfrom there if no
* called from the dropdown, the user can create a new option from there if no
* options are found matching his search query.
*/
async createOption({ value, done }) {
const values = { select_options: clone(this.field.select_options) }
values.select_options.push({
value,
color: colors[Math.floor(Math.random() * colors.length)],
color: randomColor(),
})
try {

View file

@ -11,6 +11,7 @@ import {
EmailFieldType,
LinkRowFieldType,
NumberFieldType,
RatingFieldType,
BooleanFieldType,
DateFieldType,
LastModifiedFieldType,
@ -219,6 +220,7 @@ export default (context) => {
app.$registry.register('field', new LongTextFieldType(context))
app.$registry.register('field', new LinkRowFieldType(context))
app.$registry.register('field', new NumberFieldType(context))
app.$registry.register('field', new RatingFieldType(context))
app.$registry.register('field', new BooleanFieldType(context))
app.$registry.register('field', new DateFieldType(context))
app.$registry.register('field', new LastModifiedFieldType(context))

View file

@ -2,6 +2,7 @@ import moment from '@baserow/modules/core/moment'
import { Registerable } from '@baserow/modules/core/registry'
import ViewFilterTypeText from '@baserow/modules/database/components/view/ViewFilterTypeText'
import ViewFilterTypeNumber from '@baserow/modules/database/components/view/ViewFilterTypeNumber'
import ViewFilterTypeRating from '@baserow/modules/database/components/view/ViewFilterTypeRating'
import ViewFilterTypeSelectOptions from '@baserow/modules/database/components/view/ViewFilterTypeSelectOptions'
import ViewFilterTypeBoolean from '@baserow/modules/database/components/view/ViewFilterTypeBoolean'
import ViewFilterTypeDate from '@baserow/modules/database/components/view/ViewFilterTypeDate'
@ -10,7 +11,10 @@ import ViewFilterTypeLinkRow from '@baserow/modules/database/components/view/Vie
import { trueString } from '@baserow/modules/database/utils/constants'
import { isNumeric } from '@baserow/modules/core/utils/string'
import ViewFilterTypeFileTypeDropdown from '@baserow/modules/database/components/view/ViewFilterTypeFileTypeDropdown'
import { FormulaFieldType } from '@baserow/modules/database/fieldTypes'
import {
FormulaFieldType,
RatingFieldType,
} from '@baserow/modules/database/fieldTypes'
export class ViewFilterType extends Registerable {
/**
@ -133,7 +137,10 @@ export class EqualViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.is')
}
getInputComponent() {
getInputComponent(field) {
if (field?.type === RatingFieldType.getType()) {
return ViewFilterTypeRating
}
return ViewFilterTypeText
}
@ -144,6 +151,7 @@ export class EqualViewFilterType extends ViewFilterType {
'url',
'email',
'number',
'rating',
'phone_number',
FormulaFieldType.compatibleWithFormulaTypes('text', 'char', 'number'),
]
@ -170,7 +178,10 @@ export class NotEqualViewFilterType extends ViewFilterType {
return i18n.t('viewFilter.isNot')
}
getInputComponent() {
getInputComponent(field) {
if (field?.type === RatingFieldType.getType()) {
return ViewFilterTypeRating
}
return ViewFilterTypeText
}
@ -181,6 +192,7 @@ export class NotEqualViewFilterType extends ViewFilterType {
'url',
'email',
'number',
'rating',
'phone_number',
FormulaFieldType.compatibleWithFormulaTypes('text', 'char', 'number'),
]
@ -712,12 +724,19 @@ export class HigherThanViewFilterType extends ViewFilterType {
return '100'
}
getInputComponent() {
getInputComponent(field) {
if (field?.type === RatingFieldType.getType()) {
return ViewFilterTypeRating
}
return ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return ['number', FormulaFieldType.compatibleWithFormulaTypes('number')]
return [
'number',
'rating',
FormulaFieldType.compatibleWithFormulaTypes('number'),
]
}
matches(rowValue, filterValue, field, fieldType) {
@ -745,12 +764,19 @@ export class LowerThanViewFilterType extends ViewFilterType {
return '100'
}
getInputComponent() {
getInputComponent(field) {
if (field?.type === RatingFieldType.getType()) {
return ViewFilterTypeRating
}
return ViewFilterTypeNumber
}
getCompatibleFieldTypes() {
return ['number', FormulaFieldType.compatibleWithFormulaTypes('number')]
return [
'number',
'rating',
FormulaFieldType.compatibleWithFormulaTypes('number'),
]
}
matches(rowValue, filterValue, field, fieldType) {

View file

@ -12,19 +12,21 @@
"start": "nuxt start --hostname 0.0.0.0",
"eslint": "eslint -c .eslintrc.js --ext .js,.vue . ../premium/web-frontend",
"stylelint": "stylelint **/*.scss ../premium/web-frontend/**/*.scss --syntax scss",
"jest": "jest -i --verbose false"
"jest": "jest -i --verbose false",
"test": "yarn jest"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/i18n": "^7.0.1",
"async-mutex": "^0.3.1",
"antlr4": "4.8.0",
"async-mutex": "^0.3.1",
"axios": "^0.21.0",
"bignumber.js": "^9.0.1",
"chart.js": "2.9.4",
"cookie-universal-nuxt": "^2.1.5",
"cross-env": "^7.0.2",
"jest-serializer-vue": "^2.0.2",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"moment": "^2.26.0",

View file

@ -74,6 +74,12 @@ export class MockServer {
this.mock.onPost(`/database/rows/table/${table.id}/`).reply(200, result)
}
updateViewFilter(filterId, newValue) {
this.mock
.onPatch(`/database/views/filter/${filterId}/`, { value: newValue })
.reply(200)
}
resetMockEndpoints() {
this.mock.reset()
}

View file

@ -148,7 +148,11 @@ export class TestApp {
attachTo: rootDiv,
...kwargs,
})
await this.callFetchOnChildren(wrapper.vm)
// The vm property doesn't alway exist. See https://vue-test-utils.vuejs.org/api/wrapper/#properties
if (wrapper.vm) {
await this.callFetchOnChildren(wrapper.vm)
}
return wrapper
}

View file

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Rating component Customized rating component 1`] = `
<div
class="rating color--dark-blue editing"
>
<i
class="fas rating__star fa-star rating__star--selected"
/>
<i
class="fas rating__star fa-star rating__star--selected"
/>
<i
class="fas rating__star fa-star rating__star--selected"
/>
<i
class="fas rating__star fa-star"
/>
<i
class="fas rating__star fa-star"
/>
</div>
`;
exports[`Rating component Default rating component 1`] = `
<div
class="rating color--dark-orange"
>
<i
class="fas rating__star fa-star rating__star--selected"
/>
<i
class="fas rating__star fa-star rating__star--selected"
/>
<i
class="fas rating__star fa-star rating__star--selected"
/>
</div>
`;

View file

@ -0,0 +1,65 @@
import { TestApp } from '@baserow/test/helpers/testApp'
import Rating from '@baserow/modules/database/components/Rating'
describe('Rating component', () => {
let testApp = null
beforeAll(() => {
testApp = new TestApp()
})
afterEach(() => {
testApp.afterEach()
})
const mountWebhookForm = (
props = { value: 3, maxValue: 5, readOnly: true },
listeners = {}
) => {
return testApp.mount(Rating, { propsData: props, listeners })
}
const changeValue = async (wrapper, value) => {
const star = wrapper.find(`.rating :nth-child(${value})`)
await star.trigger('click')
}
test('Default rating component', async () => {
const wrapper = await mountWebhookForm()
expect(wrapper.element).toMatchSnapshot()
})
test('Customized rating component', async () => {
const wrapper = await mountWebhookForm({
value: 3,
maxValue: 5,
readOnly: false,
style: 'flag',
color: 'dark-blue',
})
expect(wrapper.element).toMatchSnapshot()
})
test('Test interactions with rating component', async () => {
const onUpdate = jest.fn()
const wrapper = await mountWebhookForm(
{
value: 3,
maxValue: 5,
readOnly: false,
},
{ update: onUpdate }
)
changeValue(wrapper, 1)
expect(onUpdate).toHaveBeenCalledWith(1)
changeValue(wrapper, 5)
expect(onUpdate).toHaveBeenCalledWith(5)
// If we click on current value, should set value to 0
changeValue(wrapper, 3)
expect(onUpdate).toHaveBeenCalledWith(0)
})
})

View file

@ -0,0 +1,177 @@
import { TestApp } from '@baserow/test/helpers/testApp'
import ViewFilterForm from '@baserow/modules/database/components/view/ViewFilterForm'
const primary = {
id: 1,
name: 'Name',
order: 0,
type: 'text',
primary: true,
text_default: '',
_: {
loading: false,
},
}
const fields = [
{
id: 2,
table_id: 196,
name: 'Stars',
order: 1,
type: 'rating',
primary: false,
max_value: 5,
color: 'dark-orange',
style: 'star',
_: {
loading: false,
},
},
{
id: 3,
table_id: 196,
name: 'Flag',
order: 2,
type: 'rating',
primary: false,
max_value: 10,
color: 'dark-red',
style: 'heart',
_: {
loading: false,
},
},
]
const view = {
id: 1,
name: 'Grid',
type: 'grid',
filter_type: 'AND',
filters: [
{
field: 1,
type: 'equal',
value: 'test',
preload_values: {},
_: { hover: false, loading: false },
id: 10,
},
{
field: 2,
type: 'equal',
value: 2,
preload_values: {},
_: { hover: false, loading: false },
id: 11,
},
],
filters_disabled: false,
_: {
selected: true,
loading: false,
},
}
describe('ViewFilterForm component', () => {
let testApp = null
let mockServer = null
beforeAll(() => {
testApp = new TestApp()
mockServer = testApp.mockServer
})
afterEach(() => {
jest.useRealTimers()
testApp.afterEach()
mockServer.resetMockEndpoints()
})
const mountViewFilterForm = async (
props = {
primary: {},
fields: [],
view: { filters: {}, _: {} },
readOnly: false,
},
listeners = {}
) => {
const wrapper = await testApp.mount(ViewFilterForm, {
propsData: props,
listeners,
})
return wrapper
}
test('Default view filter component', async () => {
const wrapper = await mountViewFilterForm()
expect(wrapper.element).toMatchSnapshot()
})
test('Full view filter component', async () => {
const wrapper = await mountViewFilterForm({
primary,
fields,
view,
readOnly: false,
})
expect(wrapper.element).toMatchSnapshot()
})
test('Test rating filter', async (done) => {
// We want to bypass some setTimeout
jest.useFakeTimers()
// Mock server filter update call
mockServer.updateViewFilter(11, 5)
// Add rating one filter
const viewClone = JSON.parse(JSON.stringify(view))
viewClone.filters = [
{
field: 2,
type: 'equal',
value: 2,
preload_values: {},
_: { hover: false, loading: false },
id: 11,
},
]
const onChange = jest.fn(() => {
// The test is about to finish
expect(wrapper.emitted().changed).toBeTruthy()
// The Five star option should be selected
expect(wrapper.element).toMatchSnapshot()
done()
})
// Mounting the component
const wrapper = await mountViewFilterForm(
{
primary,
fields,
view: viewClone,
readOnly: false,
},
{ changed: onChange }
)
// Open type dropdown
await wrapper.find('.filters__type .dropdown__selected').trigger('click')
expect(wrapper.element).toMatchSnapshot()
// Select five stars
const option = wrapper.find(
'.filters__value .rating > .rating__star:nth-child(5)'
)
await option.trigger('click')
// Wait some timers
await jest.runAllTimers()
// Test finishes only when onChange callback is called
// Wait for mockServer to respond -> see onChange callback
})
})

View file

@ -38,6 +38,14 @@ const mockedFields = {
number_negative: false,
number_type: 'INTEGER',
},
rating: {
id: 16,
name: 'rating',
order: 4,
primary: false,
table_id: 42,
type: 'rating',
},
boolean: {
id: 5,
name: 'boolean',

View file

@ -5,4 +5,5 @@ module.exports = Object.assign({}, baseConfig, {
testMatch: ['<rootDir>/test/unit/**/*.spec.js'],
displayName: 'unit',
setupFilesAfterEnv: ['./test/unit/jest.setup.js'],
snapshotSerializers: ['jest-serializer-vue'],
})

View file

@ -6974,6 +6974,13 @@ jest-runtime@^26.6.3:
strip-bom "^4.0.0"
yargs "^15.4.1"
jest-serializer-vue@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/jest-serializer-vue/-/jest-serializer-vue-2.0.2.tgz#b238ef286357ec6b480421bd47145050987d59b3"
integrity sha1-sjjvKGNX7GtIBCG9RxRQUJh9WbM=
dependencies:
pretty "2.0.0"
jest-serializer@^26.6.2:
version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-serializer/-/jest-serializer-26.6.2.tgz#d139aafd46957d3a448f3a6cdabe2919ba0742d1"
@ -9649,7 +9656,7 @@ pretty-time@^1.1.0:
resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e"
integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==
pretty@^2.0.0:
pretty@2.0.0, pretty@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/pretty/-/pretty-2.0.0.tgz#adbc7960b7bbfe289a557dc5f737619a220d06a5"
integrity sha1-rbx5YLe7/iiaVX3F9zdhmiINBqU=