mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-03-31 19:35:02 +00:00
Resolve "Add ratings field"
This commit is contained in:
parent
3e9fb65840
commit
92ace8884c
61 changed files with 3071 additions and 110 deletions
.gitignore.nvmrcchangelog.md
backend
src/baserow
tests/baserow/contrib/database
premium
backend/tests/baserow_premium/export
web-frontend
web-frontend
.eslintrc.jsjsconfig.jsonpackage.jsonyarn.lock
locales
modules
core
assets/scss/components
components
mixins
utils
database
test
fixtures
helpers
unit
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -112,7 +112,6 @@ out/
|
|||
|
||||
# vscode config files
|
||||
.vscode
|
||||
jsconfig.json
|
||||
vetur.config.js
|
||||
|
||||
formula/out/
|
||||
formula/out/
|
||||
|
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
12
|
|
@ -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())
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
]
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
14
premium/web-frontend/jsconfig.json
Normal file
14
premium/web-frontend/jsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@baserow_premium/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".nuxt"
|
||||
]
|
||||
}
|
|
@ -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: '',
|
||||
},
|
||||
}
|
||||
|
|
|
@ -30,5 +30,6 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
'import/order': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
},
|
||||
}
|
||||
|
|
14
web-frontend/jsconfig.json
Normal file
14
web-frontend/jsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@baserow/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".nuxt"
|
||||
]
|
||||
}
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
51
web-frontend/modules/core/assets/scss/components/rating.scss
Normal file
51
web-frontend/modules/core/assets/scss/components/rating.scss
Normal file
|
@ -0,0 +1,51 @@
|
|||
.rating__star {
|
||||
font-size: 18px;
|
||||
padding-left: 2px;
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.rating {
|
||||
@extend %ellipsis;
|
||||
|
||||
display: block;
|
||||
min-height: 18px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
& > .rating__star:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.editing {
|
||||
& > .rating__star {
|
||||
color: $color-neutral-200;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// If we hover the rating, all stars should be colored and semi-transparent
|
||||
// by default
|
||||
&:hover > .rating__star {
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// but selected stars should have full opacity.
|
||||
& > .rating__star.rating__star--selected {
|
||||
color: inherit;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// stars after the hovered one should be grey and have full opacity
|
||||
& > .rating__star:hover ~ .rating__star {
|
||||
color: $color-neutral-200;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// selected star after the hovered star should be colored and be
|
||||
// semi-transparent
|
||||
& > .rating__star:hover ~ .rating__star.rating__star--selected {
|
||||
color: inherit;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
.grid-field-rating {
|
||||
@extend %ellipsis;
|
||||
|
||||
@include fixed-height(32px, 13px);
|
||||
|
||||
padding-left: 5px;
|
||||
user-select: none;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -15,3 +15,7 @@ export const colors = [
|
|||
'dark-red',
|
||||
'dark-gray',
|
||||
]
|
||||
|
||||
export const randomColor = () => {
|
||||
return colors[Math.floor(Math.random() * colors.length)]
|
||||
}
|
||||
|
|
53
web-frontend/modules/database/components/Rating.vue
Normal file
53
web-frontend/modules/database/components/Rating.vue
Normal file
|
@ -0,0 +1,53 @@
|
|||
<template functional>
|
||||
<div
|
||||
class="rating"
|
||||
:class="[
|
||||
data.staticClass,
|
||||
`color--${props.color}`,
|
||||
props.readOnly ? '' : 'editing',
|
||||
]"
|
||||
>
|
||||
<i
|
||||
v-for="index in props.readOnly ? props.value : props.maxValue"
|
||||
:key="index"
|
||||
class="fas rating__star"
|
||||
:class="{
|
||||
[`fa-${props.ratingStyle}`]: true,
|
||||
'rating__star--selected': index <= props.value,
|
||||
}"
|
||||
@click="
|
||||
!props.readOnly &&
|
||||
listeners['update'] &&
|
||||
listeners['update'](index === props.value ? 0 : index)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Rating',
|
||||
props: {
|
||||
readOnly: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
value: {
|
||||
required: true,
|
||||
validator: () => true,
|
||||
},
|
||||
maxValue: {
|
||||
required: true,
|
||||
type: Number,
|
||||
},
|
||||
ratingStyle: {
|
||||
default: 'star',
|
||||
type: String,
|
||||
},
|
||||
color: {
|
||||
default: 'dark-orange',
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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'
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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",
|
||||
|
|
6
web-frontend/test/fixtures/mockServer.js
vendored
6
web-frontend/test/fixtures/mockServer.js
vendored
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
`;
|
65
web-frontend/test/unit/database/components/rating.spec.js
Normal file
65
web-frontend/test/unit/database/components/rating.spec.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { TestApp } from '@baserow/test/helpers/testApp'
|
||||
import Rating from '@baserow/modules/database/components/Rating'
|
||||
|
||||
describe('Rating component', () => {
|
||||
let testApp = null
|
||||
|
||||
beforeAll(() => {
|
||||
testApp = new TestApp()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
testApp.afterEach()
|
||||
})
|
||||
|
||||
const mountWebhookForm = (
|
||||
props = { value: 3, maxValue: 5, readOnly: true },
|
||||
listeners = {}
|
||||
) => {
|
||||
return testApp.mount(Rating, { propsData: props, listeners })
|
||||
}
|
||||
|
||||
const changeValue = async (wrapper, value) => {
|
||||
const star = wrapper.find(`.rating :nth-child(${value})`)
|
||||
|
||||
await star.trigger('click')
|
||||
}
|
||||
|
||||
test('Default rating component', async () => {
|
||||
const wrapper = await mountWebhookForm()
|
||||
expect(wrapper.element).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('Customized rating component', async () => {
|
||||
const wrapper = await mountWebhookForm({
|
||||
value: 3,
|
||||
maxValue: 5,
|
||||
readOnly: false,
|
||||
style: 'flag',
|
||||
color: 'dark-blue',
|
||||
})
|
||||
expect(wrapper.element).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('Test interactions with rating component', async () => {
|
||||
const onUpdate = jest.fn()
|
||||
const wrapper = await mountWebhookForm(
|
||||
{
|
||||
value: 3,
|
||||
maxValue: 5,
|
||||
readOnly: false,
|
||||
},
|
||||
{ update: onUpdate }
|
||||
)
|
||||
|
||||
changeValue(wrapper, 1)
|
||||
expect(onUpdate).toHaveBeenCalledWith(1)
|
||||
|
||||
changeValue(wrapper, 5)
|
||||
expect(onUpdate).toHaveBeenCalledWith(5)
|
||||
|
||||
// If we click on current value, should set value to 0
|
||||
changeValue(wrapper, 3)
|
||||
expect(onUpdate).toHaveBeenCalledWith(0)
|
||||
})
|
||||
})
|
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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',
|
||||
|
|
|
@ -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'],
|
||||
})
|
||||
|
|
|
@ -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=
|
||||
|
|
Loading…
Add table
Reference in a new issue