diff --git a/.gitignore b/.gitignore index b754e1f37..093a5b4fd 100644 --- a/.gitignore +++ b/.gitignore @@ -112,7 +112,6 @@ out/ # vscode config files .vscode -jsconfig.json vetur.config.js -formula/out/ \ No newline at end of file +formula/out/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..48082f72f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +12 diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index e22418fc1..eeb63bccb 100644 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -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()) diff --git a/backend/src/baserow/contrib/database/fields/field_helpers.py b/backend/src/baserow/contrib/database/fields/field_helpers.py index af4630fc0..1694823ab 100644 --- a/backend/src/baserow/contrib/database/fields/field_helpers.py +++ b/backend/src/baserow/contrib/database/fields/field_helpers.py @@ -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"}, diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 63dd7dc43..c64e46123 100644 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -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 ) diff --git a/backend/src/baserow/contrib/database/fields/models.py b/backend/src/baserow/contrib/database/fields/models.py index 5f0400419..962593d20 100644 --- a/backend/src/baserow/contrib/database/fields/models.py +++ b/backend/src/baserow/contrib/database/fields/models.py @@ -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 diff --git a/backend/src/baserow/contrib/database/migrations/0054_ratingfield.py b/backend/src/baserow/contrib/database/migrations/0054_ratingfield.py new file mode 100644 index 000000000..8aac7012a --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0054_ratingfield.py @@ -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",), + ), + ] diff --git a/backend/src/baserow/contrib/database/models.py b/backend/src/baserow/contrib/database/models.py index 27f95f5cb..b7c607205 100644 --- a/backend/src/baserow/contrib/database/models.py +++ b/backend/src/baserow/contrib/database/models.py @@ -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", diff --git a/backend/src/baserow/contrib/database/views/view_filters.py b/backend/src/baserow/contrib/database/views/view_filters.py index 143f562fb..51c24c572 100644 --- a/backend/src/baserow/contrib/database/views/view_filters.py +++ b/backend/src/baserow/contrib/database/views/view_filters.py @@ -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, diff --git a/backend/src/baserow/test_utils/fixtures/field.py b/backend/src/baserow/test_utils/fixtures/field.py index d435b3ccb..9cf3df846 100644 --- a/backend/src/baserow/test_utils/fixtures/field.py +++ b/backend/src/baserow/test_utils/fixtures/field.py @@ -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) diff --git a/backend/src/baserow/test_utils/helpers.py b/backend/src/baserow/test_utils/helpers.py index 20d5c4ddd..ae0b39174 100644 --- a/backend/src/baserow/test_utils/helpers.py +++ b/backend/src/baserow/test_utils/helpers.py @@ -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, diff --git a/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py b/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py index 142a3f413..fe77320d1 100644 --- a/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py +++ b/backend/tests/baserow/contrib/database/api/rows/test_row_serializers.py @@ -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, diff --git a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py index 43c36f616..ca5ff2406 100644 --- a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py +++ b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py @@ -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() diff --git a/backend/tests/baserow/contrib/database/export/test_export_handler.py b/backend/tests/baserow/contrib/database/export/test_export_handler.py index 6b8c37975..49dc4783b 100644 --- a/backend/tests/baserow/contrib/database/export/test_export_handler.py +++ b/backend/tests/baserow/contrib/database/export/test_export_handler.py @@ -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," diff --git a/backend/tests/baserow/contrib/database/field/test_field_types.py b/backend/tests/baserow/contrib/database/field/test_field_types.py index ff3930321..a4c0cb19a 100644 --- a/backend/tests/baserow/contrib/database/field/test_field_types.py +++ b/backend/tests/baserow/contrib/database/field/test_field_types.py @@ -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", diff --git a/backend/tests/baserow/contrib/database/field/test_multiple_select_field_type.py b/backend/tests/baserow/contrib/database/field/test_multiple_select_field_type.py index 764448234..e5357e0b5 100644 --- a/backend/tests/baserow/contrib/database/field/test_multiple_select_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_multiple_select_field_type.py @@ -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 diff --git a/backend/tests/baserow/contrib/database/field/test_rating_field_type.py b/backend/tests/baserow/contrib/database/field/test_rating_field_type.py new file mode 100644 index 000000000..f4f9f8342 --- /dev/null +++ b/backend/tests/baserow/contrib/database/field/test_rating_field_type.py @@ -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), + ] diff --git a/changelog.md b/changelog.md index 20cf77d7e..000240dc6 100644 --- a/changelog.md +++ b/changelog.md @@ -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. diff --git a/premium/backend/tests/baserow_premium/export/test_premium_export_types.py b/premium/backend/tests/baserow_premium/export/test_premium_export_types.py index 8146498ec..5c3d1293c 100644 --- a/premium/backend/tests/baserow_premium/export/test_premium_export_types.py +++ b/premium/backend/tests/baserow_premium/export/test_premium_export_types.py @@ -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> diff --git a/premium/web-frontend/jsconfig.json b/premium/web-frontend/jsconfig.json new file mode 100644 index 000000000..9b232de72 --- /dev/null +++ b/premium/web-frontend/jsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@baserow_premium/*": [ + "./*" + ] + } + }, + "exclude": [ + "node_modules", + ".nuxt" + ] +} \ No newline at end of file diff --git a/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewOptionForm.vue b/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewOptionForm.vue index 452524118..49962b88f 100644 --- a/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewOptionForm.vue +++ b/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewOptionForm.vue @@ -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: '', }, } diff --git a/web-frontend/.eslintrc.js b/web-frontend/.eslintrc.js index 737d5b257..6a1e1a2c5 100644 --- a/web-frontend/.eslintrc.js +++ b/web-frontend/.eslintrc.js @@ -30,5 +30,6 @@ module.exports = { }, ], 'import/order': 'off', + 'vue/html-self-closing': 'off', }, } diff --git a/web-frontend/jsconfig.json b/web-frontend/jsconfig.json new file mode 100644 index 000000000..d5c98b39f --- /dev/null +++ b/web-frontend/jsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@baserow/*": [ + "./*" + ] + } + }, + "exclude": [ + "node_modules", + ".nuxt" + ] +} diff --git a/web-frontend/locales/en.js b/web-frontend/locales/en.js index 86040d5d8..e0ddb3998 100644 --- a/web-frontend/locales/en.js +++ b/web-frontend/locales/en.js @@ -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.', diff --git a/web-frontend/locales/fr.js b/web-frontend/locales/fr.js index 3ce3ef084..c27a61184 100644 --- a/web-frontend/locales/fr.js +++ b/web-frontend/locales/fr.js @@ -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.', diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index c9d25c041..d1d249dce 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -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'; diff --git a/web-frontend/modules/core/assets/scss/components/color_select.scss b/web-frontend/modules/core/assets/scss/components/color_select.scss index 84b07c9b1..cfa947765 100644 --- a/web-frontend/modules/core/assets/scss/components/color_select.scss +++ b/web-frontend/modules/core/assets/scss/components/color_select.scss @@ -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); } diff --git a/web-frontend/modules/core/assets/scss/components/colors.scss b/web-frontend/modules/core/assets/scss/components/colors.scss index 0bbf9dd52..a4ed05017 100644 --- a/web-frontend/modules/core/assets/scss/components/colors.scss +++ b/web-frontend/modules/core/assets/scss/components/colors.scss @@ -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; +} diff --git a/web-frontend/modules/core/assets/scss/components/fields/rating.scss b/web-frontend/modules/core/assets/scss/components/fields/rating.scss new file mode 100644 index 000000000..8c91d0c7c --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/fields/rating.scss @@ -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; +} diff --git a/web-frontend/modules/core/assets/scss/components/filters.scss b/web-frontend/modules/core/assets/scss/components/filters.scss index 37f927bdb..2ba1a3e4b 100644 --- a/web-frontend/modules/core/assets/scss/components/filters.scss +++ b/web-frontend/modules/core/assets/scss/components/filters.scss @@ -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; diff --git a/web-frontend/modules/core/assets/scss/components/rating.scss b/web-frontend/modules/core/assets/scss/components/rating.scss new file mode 100644 index 000000000..859d48ef4 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/rating.scss @@ -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; + } + } +} diff --git a/web-frontend/modules/core/assets/scss/components/views/grid/rating.scss b/web-frontend/modules/core/assets/scss/components/views/grid/rating.scss new file mode 100644 index 000000000..480b72088 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/views/grid/rating.scss @@ -0,0 +1,8 @@ +.grid-field-rating { + @extend %ellipsis; + + @include fixed-height(32px, 13px); + + padding-left: 5px; + user-select: none; +} diff --git a/web-frontend/modules/core/components/ColorSelectContext.vue b/web-frontend/modules/core/components/ColorSelectContext.vue index 7e0026acd..e1d90058e 100644 --- a/web-frontend/modules/core/components/ColorSelectContext.vue +++ b/web-frontend/modules/core/components/ColorSelectContext.vue @@ -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 diff --git a/web-frontend/modules/core/components/Dropdown.vue b/web-frontend/modules/core/components/Dropdown.vue index 442d63d41..d0fedce37 100644 --- a/web-frontend/modules/core/components/Dropdown.vue +++ b/web-frontend/modules/core/components/Dropdown.vue @@ -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> diff --git a/web-frontend/modules/core/components/DropdownItem.vue b/web-frontend/modules/core/components/DropdownItem.vue index b397d33a7..3b9e55b74 100644 --- a/web-frontend/modules/core/components/DropdownItem.vue +++ b/web-frontend/modules/core/components/DropdownItem.vue @@ -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 }} diff --git a/web-frontend/modules/core/mixins/dropdownItem.js b/web-frontend/modules/core/mixins/dropdownItem.js index f0773438b..ff4af84b9 100644 --- a/web-frontend/modules/core/mixins/dropdownItem.js +++ b/web-frontend/modules/core/mixins/dropdownItem.js @@ -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) }, diff --git a/web-frontend/modules/core/utils/colors.js b/web-frontend/modules/core/utils/colors.js index 617a47b5b..fcce4b7fa 100644 --- a/web-frontend/modules/core/utils/colors.js +++ b/web-frontend/modules/core/utils/colors.js @@ -15,3 +15,7 @@ export const colors = [ 'dark-red', 'dark-gray', ] + +export const randomColor = () => { + return colors[Math.floor(Math.random() * colors.length)] +} diff --git a/web-frontend/modules/database/components/Rating.vue b/web-frontend/modules/database/components/Rating.vue new file mode 100644 index 000000000..6c5c8cb4a --- /dev/null +++ b/web-frontend/modules/database/components/Rating.vue @@ -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> diff --git a/web-frontend/modules/database/components/card/RowCardFieldRating.vue b/web-frontend/modules/database/components/card/RowCardFieldRating.vue new file mode 100644 index 000000000..6daa3c868 --- /dev/null +++ b/web-frontend/modules/database/components/card/RowCardFieldRating.vue @@ -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> diff --git a/web-frontend/modules/database/components/field/FieldRatingSubForm.vue b/web-frontend/modules/database/components/field/FieldRatingSubForm.vue new file mode 100644 index 000000000..7b871b660 --- /dev/null +++ b/web-frontend/modules/database/components/field/FieldRatingSubForm.vue @@ -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> diff --git a/web-frontend/modules/database/components/field/FieldSelectOptions.vue b/web-frontend/modules/database/components/field/FieldSelectOptions.vue index c0d4318ae..5591ec570 100644 --- a/web-frontend/modules/database/components/field/FieldSelectOptions.vue +++ b/web-frontend/modules/database/components/field/FieldSelectOptions.vue @@ -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) }, diff --git a/web-frontend/modules/database/components/row/RowEditFieldRating.vue b/web-frontend/modules/database/components/row/RowEditFieldRating.vue new file mode 100644 index 000000000..5856ca839 --- /dev/null +++ b/web-frontend/modules/database/components/row/RowEditFieldRating.vue @@ -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> diff --git a/web-frontend/modules/database/components/view/ViewFilter.vue b/web-frontend/modules/database/components/view/ViewFilter.vue index c72f94e5a..f4e7f6f76 100644 --- a/web-frontend/modules/database/components/view/ViewFilter.vue +++ b/web-frontend/modules/database/components/view/ViewFilter.vue @@ -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, diff --git a/web-frontend/modules/database/components/view/ViewFilterContext.vue b/web-frontend/modules/database/components/view/ViewFilterForm.vue similarity index 95% rename from web-frontend/modules/database/components/view/ViewFilterContext.vue rename to web-frontend/modules/database/components/view/ViewFilterForm.vue index de91a5863..a5688f9f5 100644 --- a/web-frontend/modules/database/components/view/ViewFilterContext.vue +++ b/web-frontend/modules/database/components/view/ViewFilterForm.vue @@ -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) }, }, } diff --git a/web-frontend/modules/database/components/view/ViewFilterTypeRating.vue b/web-frontend/modules/database/components/view/ViewFilterTypeRating.vue new file mode 100644 index 000000000..486fb365d --- /dev/null +++ b/web-frontend/modules/database/components/view/ViewFilterTypeRating.vue @@ -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> diff --git a/web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldRating.vue b/web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldRating.vue new file mode 100644 index 000000000..347d538eb --- /dev/null +++ b/web-frontend/modules/database/components/view/grid/fields/FunctionalGridViewFieldRating.vue @@ -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> diff --git a/web-frontend/modules/database/components/view/grid/fields/GridViewFieldRating.vue b/web-frontend/modules/database/components/view/grid/fields/GridViewFieldRating.vue new file mode 100644 index 000000000..d816cce17 --- /dev/null +++ b/web-frontend/modules/database/components/view/grid/fields/GridViewFieldRating.vue @@ -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> diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index 7f2fe5ca6..6ce281dae 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -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' diff --git a/web-frontend/modules/database/mixins/selectOptions.js b/web-frontend/modules/database/mixins/selectOptions.js index ec8d13cd9..0e3164d2f 100644 --- a/web-frontend/modules/database/mixins/selectOptions.js +++ b/web-frontend/modules/database/mixins/selectOptions.js @@ -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 { diff --git a/web-frontend/modules/database/plugin.js b/web-frontend/modules/database/plugin.js index 116507e41..947ed8df0 100644 --- a/web-frontend/modules/database/plugin.js +++ b/web-frontend/modules/database/plugin.js @@ -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)) diff --git a/web-frontend/modules/database/viewFilters.js b/web-frontend/modules/database/viewFilters.js index 839f7855b..ef6d0b4b7 100644 --- a/web-frontend/modules/database/viewFilters.js +++ b/web-frontend/modules/database/viewFilters.js @@ -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) { diff --git a/web-frontend/package.json b/web-frontend/package.json index b06bc6d12..39744d7bb 100644 --- a/web-frontend/package.json +++ b/web-frontend/package.json @@ -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", diff --git a/web-frontend/test/fixtures/mockServer.js b/web-frontend/test/fixtures/mockServer.js index bd92bea11..9213f49be 100644 --- a/web-frontend/test/fixtures/mockServer.js +++ b/web-frontend/test/fixtures/mockServer.js @@ -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() } diff --git a/web-frontend/test/helpers/testApp.js b/web-frontend/test/helpers/testApp.js index 5d2d3ce5a..4e41fe1c8 100644 --- a/web-frontend/test/helpers/testApp.js +++ b/web-frontend/test/helpers/testApp.js @@ -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 } diff --git a/web-frontend/test/unit/database/components/__snapshots__/rating.spec.js.snap b/web-frontend/test/unit/database/components/__snapshots__/rating.spec.js.snap new file mode 100644 index 000000000..a488ecb2b --- /dev/null +++ b/web-frontend/test/unit/database/components/__snapshots__/rating.spec.js.snap @@ -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> +`; diff --git a/web-frontend/test/unit/database/components/rating.spec.js b/web-frontend/test/unit/database/components/rating.spec.js new file mode 100644 index 000000000..2f0cdf9ee --- /dev/null +++ b/web-frontend/test/unit/database/components/rating.spec.js @@ -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) + }) +}) diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap new file mode 100644 index 000000000..a1349b982 --- /dev/null +++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap @@ -0,0 +1,1353 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ViewFilterForm component Default view filter component 1`] = ` +<div> + <div + style="display: none;" + > + <div + class="filters__none" + > + <div + class="filters__none-title" + > + + viewFilterContext.noFilterTitle + + </div> + + <div + class="filters__none-description" + > + + viewFilterContext.noFilterText + + </div> + </div> + </div> + + <div + class="filters_footer" + > + <a + class="filters__add" + > + <i + class="fas fa-plus" + /> + viewFilterContext.addFilter + </a> + + <!----> + </div> +</div> +`; + +exports[`ViewFilterForm component Full view filter component 1`] = ` +<div> + <div + style="display: none;" + > + <div + class="filters__none" + > + <div + class="filters__none-title" + > + + viewFilterContext.noFilterTitle + + </div> + + <div + class="filters__none-description" + > + + viewFilterContext.noFilterText + + </div> + </div> + </div> + + <div + class="filters__item" + > + <a + class="filters__remove" + > + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <span> + viewFilterContext.where + </span> + + <!----> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + Name + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Name + + </div> + + <!----> + </a> + </li> + + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Stars + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Flag + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__type" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + viewFilter.is + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.is + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.isNot + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.contains + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.containsNot + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.lengthIsLowerThan + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.isEmpty + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.isNotEmpty + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__value" + > + <input + class="input filters__value-input" + type="text" + /> + </div> + </div> + <div + class="filters__item" + > + <a + class="filters__remove" + > + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <!----> + + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + viewFilterContext.and + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <!----> + + <ul + class="select__items" + > + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilterContext.and + + </div> + + <!----> + </a> + </li> + + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilterContext.or + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + Stars + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Name + + </div> + + <!----> + </a> + </li> + + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Stars + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Flag + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__type" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + viewFilter.is + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.is + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.isNot + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.higherThan + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.lowerThan + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__value" + > + <div + class="filters__value-rating" + > + <div + class="rating color--dark-orange 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" + /> + <i + class="fas rating__star fa-star" + /> + <i + class="fas rating__star fa-star" + /> + </div> + </div> + </div> + </div> + + <div + class="filters_footer" + > + <a + class="filters__add" + > + <i + class="fas fa-plus" + /> + viewFilterContext.addFilter + </a> + + <div> + <div + class="switch switch--has-content" + > + viewFilterContext.disableAllFilters + </div> + </div> + </div> +</div> +`; + +exports[`ViewFilterForm component Test rating filter 1`] = ` +<div> + <div + style="display: none;" + > + <div + class="filters__none" + > + <div + class="filters__none-title" + > + + viewFilterContext.noFilterTitle + + </div> + + <div + class="filters__none-description" + > + + viewFilterContext.noFilterText + + </div> + </div> + </div> + + <div + class="filters__item" + > + <a + class="filters__remove" + > + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <span> + viewFilterContext.where + </span> + + <!----> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + Stars + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Name + + </div> + + <!----> + </a> + </li> + + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Stars + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Flag + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__type" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + viewFilter.is + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active hover" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.is + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.isNot + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.higherThan + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.lowerThan + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__value" + > + <div + class="filters__value-rating" + > + <div + class="rating color--dark-orange 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" + /> + <i + class="fas rating__star fa-star" + /> + <i + class="fas rating__star fa-star" + /> + </div> + </div> + </div> + </div> + + <div + class="filters_footer" + > + <a + class="filters__add" + > + <i + class="fas fa-plus" + /> + viewFilterContext.addFilter + </a> + + <div> + <div + class="switch switch--has-content" + > + viewFilterContext.disableAllFilters + </div> + </div> + </div> +</div> +`; + +exports[`ViewFilterForm component Test rating filter 2`] = ` +<div> + <div + style="display: none;" + > + <div + class="filters__none" + > + <div + class="filters__none-title" + > + + viewFilterContext.noFilterTitle + + </div> + + <div + class="filters__none-description" + > + + viewFilterContext.noFilterText + + </div> + </div> + </div> + + <div + class="filters__item" + > + <a + class="filters__remove" + > + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <span> + viewFilterContext.where + </span> + + <!----> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + Stars + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Name + + </div> + + <!----> + </a> + </li> + + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Stars + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + Flag + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__type" + > + <div + class="dropdown dropdown--floating dropdown--tiny" + > + <a + class="dropdown__selected" + > + <!----> + + viewFilter.is + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <div + class="select__search" + > + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active hover" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.is + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.isNot + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.higherThan + + </div> + + <!----> + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + + viewFilter.lowerThan + + </div> + + <!----> + </a> + </li> + </ul> + </div> + </div> + </div> + + <div + class="filters__value" + > + <div + class="filters__value-rating" + > + <div + class="rating color--dark-orange 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 rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + </div> + </div> + </div> + </div> + + <div + class="filters_footer" + > + <a + class="filters__add" + > + <i + class="fas fa-plus" + /> + viewFilterContext.addFilter + </a> + + <div> + <div + class="switch switch--has-content" + > + viewFilterContext.disableAllFilters + </div> + </div> + </div> +</div> +`; diff --git a/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js b/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js new file mode 100644 index 000000000..e127a8f6f --- /dev/null +++ b/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js @@ -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 + }) +}) diff --git a/web-frontend/test/unit/database/fieldTypes.spec.js b/web-frontend/test/unit/database/fieldTypes.spec.js index 7858c1375..59e3ebb92 100644 --- a/web-frontend/test/unit/database/fieldTypes.spec.js +++ b/web-frontend/test/unit/database/fieldTypes.spec.js @@ -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', diff --git a/web-frontend/test/unit/jest.config.js b/web-frontend/test/unit/jest.config.js index 072e15caa..e905d61fe 100644 --- a/web-frontend/test/unit/jest.config.js +++ b/web-frontend/test/unit/jest.config.js @@ -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'], }) diff --git a/web-frontend/yarn.lock b/web-frontend/yarn.lock index a6f371e41..72b251401 100644 --- a/web-frontend/yarn.lock +++ b/web-frontend/yarn.lock @@ -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=