1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Resolve "Multiple select field"

This commit is contained in:
Sascha Jullmann 2021-10-04 15:54:29 +00:00
parent 0330f2ff01
commit 7f53cfcda4
54 changed files with 4749 additions and 318 deletions

View file

@ -69,6 +69,7 @@ class DatabaseConfig(AppConfig):
EmailFieldType,
FileFieldType,
SingleSelectFieldType,
MultipleSelectFieldType,
PhoneNumberFieldType,
)
@ -84,12 +85,28 @@ class DatabaseConfig(AppConfig):
field_type_registry.register(LinkRowFieldType())
field_type_registry.register(FileFieldType())
field_type_registry.register(SingleSelectFieldType())
field_type_registry.register(MultipleSelectFieldType())
field_type_registry.register(PhoneNumberFieldType())
from .fields.field_converters import LinkRowFieldConverter, FileFieldConverter
from .fields.field_converters import (
LinkRowFieldConverter,
FileFieldConverter,
TextFieldToMultipleSelectFieldConverter,
MultipleSelectFieldToTextFieldConverter,
MultipleSelectFieldToSingleSelectFieldConverter,
SingleSelectFieldToMultipleSelectFieldConverter,
)
field_converter_registry.register(LinkRowFieldConverter())
field_converter_registry.register(FileFieldConverter())
field_converter_registry.register(TextFieldToMultipleSelectFieldConverter())
field_converter_registry.register(MultipleSelectFieldToTextFieldConverter())
field_converter_registry.register(
MultipleSelectFieldToSingleSelectFieldConverter()
)
field_converter_registry.register(
SingleSelectFieldToMultipleSelectFieldConverter()
)
from .views.view_types import GridViewType, FormViewType
@ -119,6 +136,8 @@ class DatabaseConfig(AppConfig):
SingleSelectNotEqualViewFilterType,
LinkRowHasViewFilterType,
LinkRowHasNotViewFilterType,
MultipleSelectHasViewFilterType,
MultipleSelectHasNotViewFilterType,
)
view_filter_type_registry.register(EqualViewFilterType())
@ -143,6 +162,8 @@ class DatabaseConfig(AppConfig):
view_filter_type_registry.register(BooleanViewFilterType())
view_filter_type_registry.register(EmptyViewFilterType())
view_filter_type_registry.register(NotEmptyViewFilterType())
view_filter_type_registry.register(MultipleSelectHasViewFilterType())
view_filter_type_registry.register(MultipleSelectHasNotViewFilterType())
from .application_types import DatabaseApplicationType

View file

@ -104,3 +104,18 @@ class ReservedBaserowFieldNameException(Exception):
class InvalidBaserowFieldName(Exception):
"""Raised when a field name is not provided or an invalid blank field name is
provided."""
class AllProvidedMultipleSelectValuesMustBeIntegers(Exception):
"""
Raised when one tries to create or update a row for a MultipleSelectField that
contains a value other than an integer.
"""
class AllProvidedMultipleSelectValuesMustBeSelectOption(Exception):
"""
Raised when one tries to create or update a row for a MultipleSelectField that
contains a SelectOption ID that either does not exists or does not belong to the
field.
"""

View file

@ -1,5 +1,19 @@
from .registries import FieldConverter
from .models import LinkRowField, FileField
from dataclasses import dataclass
from psycopg2 import sql
from django.db import models, transaction
from baserow.contrib.database.fields.handler import FieldHandler
from baserow.contrib.database.db.schema import lenient_schema_editor
from .registries import FieldConverter, field_type_registry
from .models import (
LinkRowField,
FileField,
MultipleSelectField,
SingleSelectField,
SelectOption,
)
class RecreateFieldConverter(FieldConverter):
@ -55,3 +69,584 @@ class FileFieldConverter(RecreateFieldConverter):
return (
isinstance(from_field, FileField) and not isinstance(to_field, FileField)
) or (not isinstance(from_field, FileField) and isinstance(to_field, FileField))
@dataclass
class MultipleSelectConversionConfig:
"""Dataclass for holding several configuration options"""
text_delimiter: str = ","
text_delimiter_output: str = ", "
quote_sign: str = '"'
# This value determines how many unique values
# are accepted as new select_options.
# If there are more unique values then there
# will be no conversion.
new_select_options_threshold: int = 100
# This regex can be used in Postgres regex functions.
# It makes sure that a string is split by comma
# while ignoring commas inside 'quote_sign'
regex_split: str = ',\\s?(?=(?:[^"]*"[^"]*")*[^"]*$)'
trim_empty_and_quote: str = f" {quote_sign}"
text_delimiter_search: str = f"%{text_delimiter}%"
allowed_select_options_length: int = SelectOption.get_max_value_length()
class MultipleSelectConversionBase(MultipleSelectConversionConfig):
"""
Base class with specific helper methods which holds all the information and
configuration that is needed in a conversion from a multiple select field to
another field type, as well as in a conversion from another field type to a
multiple select field.
"""
def __init__(
self,
from_field,
to_field,
from_model_field,
to_model_field,
):
if isinstance(to_field, MultipleSelectField):
self.multiple_select_field = to_field
self.multiple_select_model_field = to_model_field
else:
self.multiple_select_field = from_field
self.multiple_select_model_field = from_model_field
self.through_model = self.multiple_select_model_field.remote_field.through
self.through_table_fields = self.through_model._meta.get_fields()
self.through_table_name = self.through_model._meta.db_table
self.through_table_column_name = self.through_table_fields[
1
].get_attname_column()[1]
self.through_select_option_column_name = self.through_table_fields[
2
].get_attname_column()[1]
def insert_into_many_relationship(
self,
connection,
subselect: sql.SQL,
):
"""
Helper method in order to insert values into the many to many through table.
It expects a subselect with two columns representing the values to be inserted:
select table_row_id, select_option_id from x
It is required that the select options exists before this query runs.
"""
with connection.cursor() as cursor:
cursor.execute(
sql.SQL("insert into {table} ({column_1}, {column_2}) {vals}").format(
table=sql.Identifier(self.through_table_name),
column_1=sql.Identifier(self.through_table_column_name),
column_2=sql.Identifier(self.through_select_option_column_name),
vals=subselect,
),
)
def _get_trim_and_split_field_values_query(self, model, field):
"""
Creates a sql statement for the table of the given model which will split the
contents of the column of the given field by the configured regex and
subsequently trim the created strings by the configured trim settings.
"""
return sql.SQL(
"""
select
trim(
both {trimmed} from
unnest(regexp_split_to_array({column}::text, {regex}))
) as col
from
{table}
"""
).format(
table=sql.Identifier(model._meta.db_table),
trimmed=sql.Literal(self.trim_empty_and_quote),
column=sql.Identifier(field.db_column),
regex=sql.Literal(self.regex_split),
)
def count_unique_field_values_options(self, connection, model, field):
"""
Counts the unique field values of a given field type.
"""
subselect = self._get_trim_and_split_field_values_query(model, field)
query = sql.SQL(
"""
select
count(distinct col)
from
({table_select}) as tmp_table
where
col != ''
"""
).format(
table_select=subselect,
)
with connection.cursor() as cursor:
cursor.execute(query)
res = cursor.fetchall()
return res[0][0]
def extract_field_values_to_options(self, connection, model, field):
"""
Extracts the distinct values for a specific field type over all the existing
rows into one column with one row per value.
This is needed in order to generate the select_options when converting from any
text field to a multiple_select field.
:return: A list of select_options.
:rtype: list.
"""
subselect = self._get_trim_and_split_field_values_query(model, field)
query = sql.SQL(
"""
select
distinct left(col, {select_options_length})
from
({table_select}) as tmp_table
where
col != ''
"""
).format(
table_select=subselect,
select_options_length=sql.Literal(self.allowed_select_options_length),
)
with connection.cursor() as cursor:
cursor.execute(query)
res = cursor.fetchall()
options = [{"value": x[0], "color": "blue"} for x in res]
return options
@staticmethod
def update_column_with_values(
connection, values: sql.SQL, table: str, db_column: str
):
"""
Helper method in order to update a table column with a list of values. This is
needed when converting to a field_type which has it's own column in the table
and we want to insert the values from the multiple_select field at the specific
rows.
It expects a subselect with two columns representing the values to be inserted:
select table_row_id, value_to_be_inserted from x
"""
update_stmt = sql.SQL(
"""
update {table} as u
set {db_column} = vals.new_value
from ({values_list}) as vals(id, new_value)
where vals.id = u.id
"""
).format(
table=sql.Identifier(table),
db_column=sql.Identifier(db_column),
values_list=values,
)
with connection.cursor() as cursor:
cursor.execute(update_stmt)
@staticmethod
def add_temporary_text_field_to_model(model, db_column):
"""
Adds a temporary text field to the given model with the provided db_column name.
"""
tmp_field_name = "tmp_" + db_column
models.TextField(
null=True, blank=True, db_column=db_column
).contribute_to_class(model, tmp_field_name)
tmp_model_field = model._meta.get_field(tmp_field_name)
return tmp_model_field, tmp_field_name
class TextFieldToMultipleSelectFieldConverter(FieldConverter):
"""
This is a converter class for converting from any Text, Number or DateField to a
MultipleSelectField.
When converting from any of the mentioned FieldTypes we want to make sure that the
their values get correctly converted to the destination field type. We make use of
the lenient_schema editor here, as well as the "get_alter_column_prepare_old_value"
function of the respective field type.
In order to actually convert the values, a temporary text column is being created
which will be the receiver column of the conversion with the lenient_schema_editor
and the "get_alter_column_prepare_old_value" function. The from_field gets
converted to a temporary text column.
Afterwards this temporary text column will be the source for the select_options that
have to be created.
"""
type = "text_to_multiple_select"
def is_applicable(self, from_model, from_field, to_field):
return (
not isinstance(from_field, MultipleSelectField)
and not isinstance(from_field, SingleSelectField)
and isinstance(to_field, MultipleSelectField)
)
def alter_field(
self,
from_field,
to_field,
from_model,
to_model,
from_model_field,
to_model_field,
user,
connection,
):
from_field_type = field_type_registry.get_by_model(from_field)
helper = MultipleSelectConversionBase(
from_field,
to_field,
from_model_field,
to_model_field,
)
# The lenient_schema_editor is needed so that field type specific conversions
# will be respected when converting to a MultipleSelectField.
with lenient_schema_editor(
connection,
from_field_type.get_alter_column_prepare_old_value(
connection, from_field, to_field
),
None,
) as schema_editor:
# Convert the existing column to a temporary text field.
tmp_model_field, _ = helper.add_temporary_text_field_to_model(
to_model, from_field.db_column
)
schema_editor.alter_field(to_model, from_model_field, tmp_model_field)
# Add the MultipleSelect field to the table
schema_editor.add_field(to_model, to_model_field)
# Since we are converting to a multiple select field we might have to
# create select options before we can then populate the table with the
# given select options.
has_select_options = to_field.select_options.count() > 0
values_query = sql.SQL(
"""
SELECT
sub.id,
opt.id
FROM (
SELECT
left(
trim(both {trimmed} from distinct_value),
{select_options_length}
) as value,
id
FROM
(
SELECT
a.elem as distinct_value,
t.id as id,
min(index) as first_index
FROM
{table_name} as t
LEFT JOIN LATERAL
unnest(
regexp_split_to_array({table_column_name}, {regex}))
with ordinality as a(elem, index) on true
GROUP BY
a.elem,
t.id
) as innersub
order by
first_index
) as sub
INNER JOIN database_selectoption opt ON
opt.value = sub.value
WHERE opt.field_id = {field_id}
"""
).format(
field_id=sql.Literal(to_field.id),
table_name=sql.Identifier(to_model._meta.db_table),
table_column_name=sql.Identifier(
tmp_model_field.get_attname_column()[1]
),
trimmed=sql.Literal(helper.trim_empty_and_quote),
regex=sql.Literal(helper.regex_split),
select_options_length=sql.Literal(helper.allowed_select_options_length),
)
# If the amount of unique new select_options that need to be created is
# lower than the allowed threshold and the user has not provided any
# select_options themselves, we need to extract the options and create them.
with transaction.atomic():
if (
not has_select_options
and helper.count_unique_field_values_options(
connection,
to_model,
tmp_model_field,
)
<= helper.new_select_options_threshold
):
options = helper.extract_field_values_to_options(
connection, to_model, tmp_model_field
)
field_handler = FieldHandler()
field_handler.update_field_select_options(user, to_field, options)
helper.insert_into_many_relationship(
connection,
values_query,
)
schema_editor.remove_field(to_model, tmp_model_field)
class MultipleSelectFieldToTextFieldConverter(FieldConverter):
"""
This is a converter class for converting a MultipleSelectField to any Text, Number
or DateField.
When converting to any of the mentioned FieldTypes we want to make sure that the
values inserted as a select_option to the MultipleSelectFieldType get correctly
converted to the destination field type. We make use of the lenient_schema editor
here, as well as the "get_alter_column_prepare_new_value" function of the respective
field type.
In order to actually convert the values, a temporary column is being created which
is first updated with the aggregated values of the MultipleSelectFieldType.
Afterwards this temporary column gets converted with the lenient_schema editor.
"""
type = "multiple_select_to_text"
def is_applicable(self, from_model, from_field, to_field):
return (
isinstance(from_field, MultipleSelectField)
and not isinstance(to_field, MultipleSelectField)
and not isinstance(to_field, SingleSelectField)
)
def alter_field(
self,
from_field,
to_field,
from_model,
to_model,
from_model_field,
to_model_field,
user,
connection,
):
to_field_type = field_type_registry.get_by_model(to_field)
helper = MultipleSelectConversionBase(
from_field,
to_field,
from_model_field,
to_model_field,
)
with lenient_schema_editor(
connection,
None,
to_field_type.get_alter_column_prepare_new_value(
connection, from_field, to_field
),
) as schema_editor:
tmp_model_field, _ = helper.add_temporary_text_field_to_model(
from_model, from_field.db_column
)
schema_editor.add_field(from_model, tmp_model_field)
aggregated_multiple_select_values = sql.SQL(
"""
select
tab.id as row_id,
string_agg(
case when ds.value like {text_delimiter_search}
then concat({quote}, ds.value, {quote})
else ds.value
end, {delimiter_output}
order by dm.id
) as agg_value
from
{table} tab
inner join {through_table} dm on
tab.id = dm.{table_column}
inner join database_selectoption ds on
ds.id = dm.{select_option_column}
group by
tab.id
"""
).format(
table=sql.Identifier(from_model._meta.db_table),
through_table=sql.Identifier(helper.through_table_name),
table_column=sql.Identifier(helper.through_table_column_name),
select_option_column=sql.Identifier(
helper.through_select_option_column_name
),
delimiter_output=sql.Literal(helper.text_delimiter_output),
text_delimiter_search=sql.Literal(helper.text_delimiter_search),
quote=sql.Literal(helper.quote_sign),
)
helper.update_column_with_values(
connection,
aggregated_multiple_select_values,
from_model._meta.db_table,
tmp_model_field.db_column,
)
schema_editor.remove_field(from_model, from_model_field)
schema_editor.alter_field(from_model, tmp_model_field, to_model_field)
class MultipleSelectFieldToSingleSelectFieldConverter(FieldConverter):
"""
Conversion class for converting a MultipleSelectField to a SingleSelectField. When
converting from a MultipleSelectField we want to keep the already added select
options on the field, but make sure that the first added select option on any
given row will be the one that will be added to the SingleSelectField.
"""
type = "multiple_select_to_single_select"
def is_applicable(self, from_model, from_field, to_field):
return isinstance(from_field, MultipleSelectField) and isinstance(
to_field, SingleSelectField
)
def alter_field(
self,
from_field,
to_field,
from_model,
to_model,
from_model_field,
to_model_field,
user,
connection,
):
helper = MultipleSelectConversionBase(
from_field,
to_field,
from_model_field,
to_model_field,
)
with connection.schema_editor() as schema_editor:
schema_editor.add_field(to_model, to_model_field)
multiple_select_first_value_query = sql.SQL(
"""
with summary as (
select
tab.id as row_id,
ds.value as option_val,
ds.id as option_id,
dm.id as rel_id,
row_number() over(partition by tab.id
order by
dm.id asc) as rank
from
{table} tab
inner join {through_table} dm on
tab.id = dm.{table_column}
inner join database_selectoption ds on
ds.id = dm.{select_option_column}
)
select
row_id,
option_id
from
summary
where
rank = 1
"""
).format(
table=sql.Identifier(from_model._meta.db_table),
through_table=sql.Identifier(helper.through_table_name),
table_column=sql.Identifier(helper.through_table_column_name),
select_option_column=sql.Identifier(
helper.through_select_option_column_name
),
)
helper.update_column_with_values(
connection,
multiple_select_first_value_query,
to_model._meta.db_table,
to_field.db_column,
)
with connection.schema_editor() as schema_editor:
schema_editor.remove_field(from_model, from_model_field)
class SingleSelectFieldToMultipleSelectFieldConverter(FieldConverter):
"""
Conversion class for converting a SingleSelectField to a MultipleSelectField. When
converting from a SingleSelectField we want to keep the already added select
options on the field and make sure that the added select option on the
SingleSelectField will be added to the MultipleSelectField.
"""
type = "single_select_to_multiple_select"
def is_applicable(self, from_model, from_field, to_field):
return isinstance(from_field, SingleSelectField) and isinstance(
to_field, MultipleSelectField
)
def alter_field(
self,
from_field,
to_field,
from_model,
to_model,
from_model_field,
to_model_field,
user,
connection,
):
helper = MultipleSelectConversionBase(
from_field,
to_field,
from_model_field,
to_model_field,
)
with connection.schema_editor() as schema_editor:
schema_editor.add_field(to_model, to_model_field)
query = sql.SQL(
"""
select
id, {from_field_name}
from {from_table_name}
where
{from_field_name} is not null
"""
).format(
from_field_name=sql.Identifier(from_model_field.name),
from_table_name=sql.Identifier(from_model._meta.db_table),
)
helper.insert_into_many_relationship(
connection,
query,
)
with connection.schema_editor() as schema_editor:
schema_editor.remove_field(from_model, from_model_field)

View file

@ -109,6 +109,16 @@ def construct_all_possible_field_kwargs(
],
}
],
"multiple_select": [
{
"name": "multiple_select",
"select_options": [
{"id": 2, "value": "C", "color": "orange"},
{"id": 3, "value": "D", "color": "yellow"},
{"id": 4, "value": "E", "color": "green"},
],
}
],
"phone_number": [{"name": "phone_number"}],
}
# If you have added a new field please add an entry into the dict above with any

View file

@ -0,0 +1,16 @@
from dataclasses import dataclass
from typing import Any, Dict
@dataclass
class AnnotatedOrder:
"""
A simple helper class which holds an annotation dictionary, as well as a Django
expression to be used in queryset.order().
This is needed in case a field types "get_order" method needs to return
an order expression, as well as an annotation on which the order expression depends.
"""
annotation: Dict[str, Any]
order: Any

View file

@ -3,12 +3,13 @@ from abc import ABC, abstractmethod
from collections import defaultdict
from datetime import datetime, date
from decimal import Decimal
from random import randrange, randint
from random import randrange, randint, sample
from typing import Any, Callable, Dict, List
from dateutil import parser
from dateutil.parser import ParserError
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.aggregates import StringAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models
@ -38,13 +39,17 @@ from .exceptions import (
LinkRowTableNotInSameDatabase,
LinkRowTableNotProvided,
IncompatiblePrimaryFieldTypeError,
AllProvidedMultipleSelectValuesMustBeIntegers,
AllProvidedMultipleSelectValuesMustBeSelectOption,
)
from .field_filters import contains_filter, AnnotatedQ, filename_contains_filter
from .fields import SingleSelectForeignKey
from .field_sortings import AnnotatedOrder
from .fields import SingleSelectForeignKey, MultipleSelectManyToManyField
from .handler import FieldHandler
from .models import (
NUMBER_TYPE_INTEGER,
NUMBER_TYPE_DECIMAL,
Field,
TextField,
LongTextField,
URLField,
@ -57,7 +62,9 @@ from .models import (
EmailField,
FileField,
SingleSelectField,
MultipleSelectField,
SelectOption,
AbstractSelectOption,
PhoneNumberField,
)
from .registries import FieldType, field_type_registry
@ -1406,9 +1413,7 @@ class FileFieldType(FieldType):
setattr(row, field_name, files)
class SingleSelectFieldType(FieldType):
type = "single_select"
model_class = SingleSelectField
class SelectOptionBaseFieldType(FieldType):
can_have_select_options = True
allowed_fields = ["select_options"]
serializer_field_names = ["select_options"]
@ -1416,6 +1421,49 @@ class SingleSelectFieldType(FieldType):
"select_options": SelectOptionSerializer(many=True, required=False)
}
def before_create(self, table, primary, values, order, user):
if "select_options" in values:
return values.pop("select_options")
def after_create(self, field, model, user, connection, before):
if before and len(before) > 0:
FieldHandler().update_field_select_options(user, field, before)
def before_update(self, from_field, to_field_values, user):
if "select_options" in to_field_values:
FieldHandler().update_field_select_options(
user, from_field, to_field_values["select_options"]
)
to_field_values.pop("select_options")
class SingleSelectFieldType(SelectOptionBaseFieldType):
type = "single_select"
model_class = SingleSelectField
def get_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
field_serializer = serializers.PrimaryKeyRelatedField(
**{
"queryset": SelectOption.objects.filter(field=instance),
"required": required,
"allow_null": not required,
**kwargs,
}
)
return field_serializer
def get_response_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
return SelectOptionSerializer(
**{
"required": required,
"allow_null": not required,
"many": False,
**kwargs,
}
)
def enhance_queryset(self, queryset, field, name):
return queryset.prefetch_related(
models.Prefetch(name, queryset=SelectOption.objects.using("default").all())
@ -1438,23 +1486,6 @@ class SingleSelectFieldType(FieldType):
# then the provided value is invalid and a validation error can be raised.
raise ValidationError(f"The provided value is not a valid option.")
def get_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
return serializers.PrimaryKeyRelatedField(
**{
"queryset": SelectOption.objects.filter(field=instance),
"required": required,
"allow_null": not required,
**kwargs,
}
)
def get_response_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
return SelectOptionSerializer(
**{"required": required, "allow_null": not required, **kwargs}
)
def get_serializer_help_text(self, instance):
return (
"This field accepts an `integer` representing the chosen select option id "
@ -1480,21 +1511,6 @@ class SingleSelectFieldType(FieldType):
**kwargs,
)
def before_create(self, table, primary, values, order, user):
if "select_options" in values:
return values.pop("select_options")
def after_create(self, field, model, user, connection, before):
if before and len(before) > 0:
FieldHandler().update_field_select_options(user, field, before)
def before_update(self, from_field, to_field_values, user):
if "select_options" in to_field_values:
FieldHandler().update_field_select_options(
user, from_field, to_field_values["select_options"]
)
to_field_values.pop("select_options")
def get_alter_column_prepare_old_value(self, connection, from_field, to_field):
"""
If the new field type isn't a single select field we can convert the plain
@ -1663,6 +1679,216 @@ class SingleSelectFieldType(FieldType):
)
class MultipleSelectFieldType(SelectOptionBaseFieldType):
type = "multiple_select"
model_class = MultipleSelectField
def get_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
field_serializer = serializers.PrimaryKeyRelatedField(
**{
"queryset": SelectOption.objects.filter(field=instance),
"required": required,
"allow_null": not required,
**kwargs,
}
)
return serializers.ListSerializer(child=field_serializer, required=required)
def get_response_serializer_field(self, instance, **kwargs):
required = kwargs.get("required", False)
return SelectOptionSerializer(
**{
"required": required,
"allow_null": not required,
"many": True,
**kwargs,
}
)
def enhance_queryset(self, queryset, field, name):
remote_field = queryset.model._meta.get_field(name).remote_field
remote_model = remote_field.model
through_model = remote_field.through
related_queryset = remote_model.objects.all().extra(
order_by=[f"{through_model._meta.db_table}.id"]
)
return queryset.prefetch_related(
models.Prefetch(name, queryset=related_queryset)
)
def prepare_value_for_db(self, instance, value):
if value is None:
return value
if not all(isinstance(x, int) for x in value):
raise AllProvidedMultipleSelectValuesMustBeIntegers
options = SelectOption.objects.filter(field=instance, id__in=value)
if len(options) != len(value):
raise AllProvidedMultipleSelectValuesMustBeSelectOption
return value
def get_serializer_help_text(self, instance):
return (
"This field accepts a list of `integer` each of which representing the"
"chosen select option id related to the field. Available ids can be found"
"when getting or listing the field. The response represents chosen field,"
"but also the value and color is exposed."
)
def random_value(self, instance, fake, cache):
"""
Selects a random sublist out of the possible options.
"""
cache_entry_name = f"field_{instance.id}_options"
if cache_entry_name not in cache:
cache[cache_entry_name] = instance.select_options.all()
select_options = cache[cache_entry_name]
# if the select_options are empty return None
if not select_options:
return None
random_choice = randint(1, len(select_options))
return sample(set([x.id for x in select_options]), random_choice)
def get_export_value(self, value, field_object):
if value is None:
return value
return [item.value for item in value.all()]
def get_human_readable_value(self, value, field_object):
export_value = self.get_export_value(value, field_object)
return ", ".join(export_value)
def get_model_field(self, instance, **kwargs):
return None
def after_model_generation(self, instance, model, field_name, manytomany_models):
select_option_meta = type(
"Meta",
(AbstractSelectOption.Meta,),
{
"managed": False,
"app_label": model._meta.app_label,
"db_tablespace": model._meta.db_tablespace,
"db_table": "database_selectoption",
"apps": model._meta.apps,
},
)
select_option_model = type(
str(f"MultipleSelectField{instance.id}SelectOption"),
(AbstractSelectOption,),
{
"Meta": select_option_meta,
"field": models.ForeignKey(
Field, on_delete=models.CASCADE, related_name="+"
),
"__module__": model.__module__,
"_generated_table_model": True,
},
)
related_name = f"reversed_field_{instance.id}"
shared_kwargs = {
"null": True,
"blank": True,
"db_table": instance.through_table_name,
"db_constraint": False,
}
MultipleSelectManyToManyField(
to=select_option_model, related_name=related_name, **shared_kwargs
).contribute_to_class(model, field_name)
MultipleSelectManyToManyField(
to=model, related_name=field_name, **shared_kwargs
).contribute_to_class(select_option_model, related_name)
# Trigger the newly created pending operations of all the models related to the
# created ManyToManyField. They need to be called manually because normally
# they are triggered when a new new model is registered. Not triggering them
# can cause a memory leak because everytime a table model is generated, it will
# register new pending operations.
apps = model._meta.apps
model_field = model._meta.get_field(field_name)
select_option_field = select_option_model._meta.get_field(related_name)
apps.do_pending_operations(model)
apps.do_pending_operations(select_option_model)
apps.do_pending_operations(model_field.remote_field.through)
apps.do_pending_operations(model)
apps.do_pending_operations(select_option_field.remote_field.through)
apps.clear_cache()
def get_export_serialized_value(self, row, field_name, cache, files_zip, storage):
cache_entry = f"{field_name}_relations"
if cache_entry not in cache:
# In order to prevent a lot of lookup queries in the through table, we want
# to fetch all the relations and add it to a temporary in memory cache
# containing a mapping of the old ids to the new ids. Every relation can
# use the cached mapped relations to find the correct id.
cache[cache_entry] = defaultdict(list)
through_model = row._meta.get_field(field_name).remote_field.through
through_model_fields = through_model._meta.get_fields()
current_field_name = through_model_fields[1].name
relation_field_name = through_model_fields[2].name
for relation in through_model.objects.all():
cache[cache_entry][
getattr(relation, f"{current_field_name}_id")
].append(getattr(relation, f"{relation_field_name}_id"))
return cache[cache_entry][row.id]
def set_import_serialized_value(
self, row, field_name, value, id_mapping, files_zip, storage
):
mapped_values = [
id_mapping["database_field_select_options"][item] for item in value
]
getattr(row, field_name).set(mapped_values)
def contains_query(self, field_name, value, model_field, field):
value = value.strip()
# If an empty value has been provided we do not want to filter at all.
if value == "":
return Q()
query = StringAgg(f"{field_name}__value", "")
return AnnotatedQ(
annotation={
f"select_option_value_{field_name}": Coalesce(query, Value(""))
},
q={f"select_option_value_{field_name}__icontains": value},
)
def get_order(self, field, field_name, view_sort):
"""
If the user wants to sort the results he expects them to be ordered
alphabetically based on the select option value and not in the id which is
stored in the table. This method generates a Case expression which maps the id
to the correct position.
"""
sort_column_name = f"{field_name}_agg_sort"
query = Coalesce(StringAgg(f"{field_name}__value", ""), Value(""))
annotation = {sort_column_name: query}
order = F(sort_column_name)
if view_sort.order == "DESC":
order = order.desc(nulls_first=True)
else:
order = order.asc(nulls_first=True)
return AnnotatedOrder(annotation=annotation, order=order)
class PhoneNumberFieldType(CharFieldMatchingRegexFieldType):
"""
A simple wrapper around a TextField which ensures any entered data is a

View file

@ -1,5 +1,9 @@
from django.db import models
from django.db.models.fields.related_descriptors import ForwardManyToOneDescriptor
from django.db.models.fields.related_descriptors import (
ForwardManyToOneDescriptor,
ManyToManyDescriptor,
)
from django.utils.functional import cached_property
class SingleSelectForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
@ -28,3 +32,64 @@ class SingleSelectForwardManyToOneDescriptor(ForwardManyToOneDescriptor):
class SingleSelectForeignKey(models.ForeignKey):
forward_related_accessor_class = SingleSelectForwardManyToOneDescriptor
class MultipleSelectManyToManyDescriptor(ManyToManyDescriptor):
"""
This is a slight modification of Djangos default ManyToManyDescriptor for the
MultipleSelectFieldType. This is needed in order to change the default ordering of
the select_options that are being returned when accessing those by calling ".all()"
on the field. The default behavior was that no ordering is applied, which in the
case for the MultipleSelectFieldType meant that the select options were ordered by
their ID. To show the select_options in the order of how the user added those to
the field, the "get_queryset" method was modified by applying an order_by. The
order_by is using the id of the through table.
"""
@cached_property
def related_manager_cls(self):
manager_class = super().related_manager_cls
class CustomManager(manager_class):
def _apply_rel_ordering(self, queryset):
return queryset.extra(order_by=[f"{self.through._meta.db_table}.id"])
def get_queryset(self):
try:
return self.instance._prefetched_objects_cache[
self.prefetch_cache_name
]
except (AttributeError, KeyError):
queryset = super().get_queryset()
return self._apply_rel_ordering(queryset)
return CustomManager
class MultipleSelectManyToManyField(models.ManyToManyField):
"""
This is a slight modification of Djangos default ManyToManyField to be used with
the MultipleSelectFieldType. A custom ManyToManyField is needed in order to apply
the custom ManyToManyDescriptor (MultipleSelectManyToManyDescriptor) to the class
of the model (on which the ManyToManyField gets added) as well as the related class.
"""
def contribute_to_class(self, cls, name, **kwargs):
super().contribute_to_class(cls, name, **kwargs)
setattr(
cls,
self.name,
MultipleSelectManyToManyDescriptor(self.remote_field, reverse=False),
)
def contribute_to_related_class(self, cls, related):
super().contribute_to_related_class(cls, related)
if (
not self.remote_field.is_hidden()
and not related.related_model._meta.swapped
):
setattr(
cls,
related.get_accessor_name(),
MultipleSelectManyToManyDescriptor(self.remote_field, reverse=True),
)

View file

@ -96,7 +96,7 @@ class Field(
return name
class SelectOption(ParentFieldTrashableModelMixin, models.Model):
class AbstractSelectOption(ParentFieldTrashableModelMixin, models.Model):
value = models.CharField(max_length=255, blank=True)
color = models.CharField(max_length=255, blank=True)
order = models.PositiveIntegerField()
@ -105,6 +105,7 @@ class SelectOption(ParentFieldTrashableModelMixin, models.Model):
)
class Meta:
abstract = True
ordering = (
"order",
"id",
@ -114,6 +115,12 @@ class SelectOption(ParentFieldTrashableModelMixin, models.Model):
return self.value
class SelectOption(AbstractSelectOption):
@classmethod
def get_max_value_length(cls):
return cls._meta.get_field("value").max_length
class TextField(Field):
text_default = models.CharField(
max_length=255,
@ -239,5 +246,20 @@ class SingleSelectField(Field):
pass
class MultipleSelectField(Field):
THROUGH_DATABASE_TABLE_PREFIX = "database_multipleselect_"
@property
def through_table_name(self):
"""
Generating a unique through table name based on the relation id.
:return: The table name of the through model.
:rtype: string
"""
return f"{self.THROUGH_DATABASE_TABLE_PREFIX}{self.id}"
class PhoneNumberField(Field):
pass

View file

@ -436,6 +436,8 @@ class FieldType(
Optionally a different expression can be generated. This is for example used
by the single select field generates a mapping achieve the correct sorting
based on the select option value.
Additionally an annotation can be returned which will get applied to the
queryset.
:param field: The related field object instance.
:type field: Field
@ -443,8 +445,9 @@ class FieldType(
:type field_name: str
:param view_sort: The view sort that must be applied.
:type view_sort: ViewSort
:return: The expression that is added directly to the model.objects.order().
:rtype: Expression or None
:return: Either the expression that is added directly to the
model.objects.order(), an AnnotatedOrderBy class or None.
:rtype: Optional[Expression, AnnotatedOrderBy, None]
"""
return None

View file

@ -0,0 +1,43 @@
# Generated by Django 3.2.6 on 2021-09-21 07:47
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("database", "0037_alter_exportjob_export_options"),
]
operations = [
migrations.CreateModel(
name="MultipleSelectField",
fields=[
(
"field_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="database.field",
),
),
],
options={
"abstract": False,
},
bases=("database.field",),
),
migrations.AlterField(
model_name="selectoption",
name="field",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="single_select_options",
to="database.field",
),
),
]

View file

@ -11,6 +11,7 @@ from baserow.contrib.database.fields.exceptions import FieldNotInTable
from baserow.contrib.database.fields.field_filters import FilterBuilder
from baserow.contrib.database.fields.models import Field
from baserow.contrib.database.fields.registries import field_type_registry
from baserow.contrib.database.fields.field_sortings import AnnotatedOrder
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.rows.signals import row_created
from .exceptions import (
@ -550,6 +551,14 @@ class ViewHandler:
field_type = model._field_objects[view_sort.field_id]["type"]
order = field_type.get_order(field, field_name, view_sort)
annotation = None
if isinstance(order, AnnotatedOrder):
annotation = order.annotation
order = order.order
if annotation is not None:
queryset = queryset.annotate(**annotation)
# If the field type does not have a specific ordering expression we can
# order the default way.

View file

@ -5,7 +5,9 @@ from math import floor, ceil
from dateutil import parser
from dateutil.parser import ParserError
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.aggregates.general import ArrayAgg
from django.db.models import Q, IntegerField, BooleanField, DateTimeField
from django.db.models.functions import Cast
from django.db.models.fields.related import ManyToManyField, ForeignKey
from pytz import timezone, all_timezones
@ -16,6 +18,7 @@ from baserow.contrib.database.fields.field_filters import (
)
from baserow.contrib.database.fields.field_types import (
CreatedOnFieldType,
MultipleSelectFieldType,
TextFieldType,
LongTextFieldType,
URLFieldType,
@ -137,6 +140,7 @@ class ContainsViewFilterType(ViewFilterType):
LastModifiedFieldType.type,
CreatedOnFieldType.type,
SingleSelectFieldType.type,
MultipleSelectFieldType.type,
NumberFieldType.type,
]
@ -509,25 +513,41 @@ class BooleanViewFilterType(ViewFilterType):
return Q()
class LinkRowHasViewFilterType(ViewFilterType):
class ManyToManyHasBaseViewFilter(ViewFilterType):
"""
The link row has filter accepts the row ID of the related table as value. It
filters the queryset so that only rows that have a relationship with the provided
row ID will remain. So if for example '10' is provided, then only rows where the
link row field has a relationship with the row '10' persists.
The many to many base filter accepts an relationship ID. It filters the queryset so
that only rows that have a relationship with the provided ID will remain. So if for
example '10' is provided, then only rows where the many to many field has a
relationship to a foreignkey with the ID of 10.
"""
type = "link_row_has"
compatible_field_types = [LinkRowFieldType.type]
def get_filter(self, field_name, value, model_field, field):
value = value.strip()
try:
return Q(**{f"{field_name}__in": [int(value)]})
# We annotate the queryset with an aggregated Array containing all the ids
# of the related field. Then we filter on this annoted column by checking
# which of the items in the array overlap with a new Array containing the
# value of the filter. That way we can make sure that chaining more than
# one filter works correctly.
return AnnotatedQ(
annotation={
f"{field_name}_array": ArrayAgg(Cast(field_name, IntegerField())),
},
q={f"{field_name}_array__overlap": [int(value)]},
)
except ValueError:
return Q()
class LinkRowHasViewFilterType(ManyToManyHasBaseViewFilter):
"""
The link row has filter accepts the row ID of the related table as value.
"""
type = "link_row_has"
compatible_field_types = [LinkRowFieldType.type]
def get_preload_values(self, view_filter):
"""
This method preloads the display name of the related value. This prevents a
@ -568,6 +588,36 @@ class LinkRowHasNotViewFilterType(NotViewFilterTypeMixin, LinkRowHasViewFilterTy
type = "link_row_has_not"
class MultipleSelectHasViewFilterType(ManyToManyHasBaseViewFilter):
"""
The multiple select has filter accepts the ID of the select_option to filter for
and filters the rows where the multiple select field has the provided select_option.
"""
type = "multiple_select_has"
compatible_field_types = [MultipleSelectFieldType.type]
def set_import_serialized_value(self, value, id_mapping):
try:
value = int(value)
except ValueError:
return ""
return str(id_mapping["database_field_select_options"].get(value, ""))
class MultipleSelectHasNotViewFilterType(
NotViewFilterTypeMixin, MultipleSelectHasViewFilterType
):
"""
The multiple select has filter accepts the ID of the select_option to filter for
and filters the rows where the multiple select field does not have the provided
select_option.
"""
type = "multiple_select_has_not"
class EmptyViewFilterType(ViewFilterType):
"""
The empty filter checks if the field value is empty, this can be '', null,
@ -589,6 +639,7 @@ class EmptyViewFilterType(ViewFilterType):
FileFieldType.type,
SingleSelectFieldType.type,
PhoneNumberFieldType.type,
MultipleSelectFieldType.type,
]
def get_filter(self, field_name, value, model_field, field):

View file

@ -12,6 +12,8 @@ from baserow.contrib.database.fields.models import (
CreatedOnField,
LastModifiedField,
LongTextField,
MultipleSelectField,
SelectOption,
URLField,
DateField,
EmailField,
@ -1069,3 +1071,245 @@ def test_created_on_field_type(api_client, data_fixture):
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
@pytest.mark.django_db
def test_multiple_select_field_type(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
database = data_fixture.create_database_application(user=user, name="Placeholder")
table = data_fixture.create_database_table(name="Example", database=database)
response = api_client.post(
reverse("api:database:fields:list", kwargs={"table_id": table.id}),
{
"name": "Multi 1",
"type": "multiple_select",
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
field_1_id = response_json["id"]
assert response_json["name"] == "Multi 1"
assert response_json["type"] == "multiple_select"
assert response_json["select_options"] == []
assert MultipleSelectField.objects.all().count() == 1
assert SelectOption.objects.all().count() == 0
response = api_client.post(
reverse("api:database:fields:list", kwargs={"table_id": table.id}),
{
"name": "Multi 2",
"type": "multiple_select",
"select_options": [{"value": "Option 1", "color": "red"}],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
field_2_id = response_json["id"]
select_options = SelectOption.objects.all()
assert len(select_options) == 1
assert select_options[0].field_id == field_2_id
assert select_options[0].value == "Option 1"
assert select_options[0].color == "red"
assert select_options[0].order == 0
assert response_json["name"] == "Multi 2"
assert response_json["type"] == "multiple_select"
assert response_json["select_options"] == [
{"id": select_options[0].id, "value": "Option 1", "color": "red"}
]
assert MultipleSelectField.objects.all().count() == 2
response = api_client.patch(
reverse("api:database:fields:item", kwargs={"field_id": field_2_id}),
{"name": "New Multi 1"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["name"] == "New Multi 1"
assert response_json["type"] == "multiple_select"
assert response_json["select_options"] == [
{"id": select_options[0].id, "value": "Option 1", "color": "red"}
]
response = api_client.patch(
reverse("api:database:fields:item", kwargs={"field_id": field_2_id}),
{
"name": "New Multi 1",
"select_options": [
{"id": select_options[0].id, "value": "Option 1 B", "color": "red 2"},
{"value": "Option 2 B", "color": "blue 2"},
],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
select_options = SelectOption.objects.all()
assert len(select_options) == 2
assert response_json["select_options"] == [
{"id": select_options[0].id, "value": "Option 1 B", "color": "red 2"},
{"id": select_options[1].id, "value": "Option 2 B", "color": "blue 2"},
]
response = api_client.patch(
reverse("api:database:fields:item", kwargs={"field_id": field_2_id}),
{"name": "New Multi 1", "select_options": []},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert SelectOption.objects.all().count() == 0
assert response_json["select_options"] == []
response = api_client.patch(
reverse("api:database:fields:item", kwargs={"field_id": field_2_id}),
{
"name": "New Multi 1",
"select_options": [
{"value": "Option 1 B", "color": "red 2"},
{"value": "Option 2 B", "color": "blue 2"},
],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
select_options = SelectOption.objects.all()
assert len(select_options) == 2
response = api_client.delete(
reverse("api:database:fields:item", kwargs={"field_id": field_2_id}),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_204_NO_CONTENT
assert MultipleSelectField.objects.all().count() == 1
assert SelectOption.objects.all().count() == 0
response = api_client.patch(
reverse("api:database:fields:item", kwargs={"field_id": field_1_id}),
{
"select_options": [
{"value": "Option 1", "color": "red"},
{"value": "Option 2", "color": "blue"},
{"value": "Option 3", "color": "green"},
{"value": "Option 4", "color": "yellow"},
],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
select_options = SelectOption.objects.all()
assert len(select_options) == 4
response = api_client.post(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
{f"field_{field_1_id}": "Nothing"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert (
response_json["detail"][f"field_{field_1_id}"]["non_field_errors"][0]["code"]
== "not_a_list"
)
response = api_client.post(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
{f"field_{field_1_id}": [999999]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_400_BAD_REQUEST
assert response_json["error"] == "ERROR_REQUEST_BODY_VALIDATION"
assert (
response_json["detail"][f"field_{field_1_id}"][0][0]["code"] == "does_not_exist"
)
response = api_client.post(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
{f"field_{field_1_id}": [select_options[0].id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json[f"field_{field_1_id}"][0]["id"] == select_options[0].id
assert response_json[f"field_{field_1_id}"][0]["value"] == "Option 1"
assert response_json[f"field_{field_1_id}"][0]["color"] == "red"
response = api_client.post(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
{f"field_{field_1_id}": [select_options[2].id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
row_id = response.json()["id"]
response = api_client.patch(
reverse(
"api:database:rows:item", kwargs={"table_id": table.id, "row_id": row_id}
),
{f"field_{field_1_id}": [select_options[2].id, select_options[0].id]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
model = table.get_model()
rows = list(model.objects.all().enhance_by_fields())
assert len(rows) == 2
field_cell = getattr(rows[1], f"field_{field_1_id}").all()
assert field_cell[0].id == select_options[2].id
assert field_cell[1].id == select_options[0].id
# Create second multiple select field
response = api_client.post(
reverse("api:database:fields:list", kwargs={"table_id": table.id}),
{
"name": "Another Multi Field",
"type": "multiple_select",
"select_options": [
{"value": "Option 1", "color": "red"},
{"value": "Option 2", "color": "blue"},
],
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
field_2_id = response_json["id"]
field_2_select_options = response_json["select_options"]
all_select_options = SelectOption.objects.all()
assert len(all_select_options) == 6
assert MultipleSelectField.objects.all().count() == 2
# Make sure we can create a row with just one field
response = api_client.post(
reverse("api:database:rows:list", kwargs={"table_id": table.id}),
{f"field_{field_2_id}": [field_2_select_options[0]["id"]]},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK

View file

@ -283,6 +283,23 @@ def test_get_row_serializer_with_user_field_names(data_fixture):
"id": SelectOption.objects.get(value="A").id,
"value": "A",
},
"multiple_select": [
{
"color": "yellow",
"id": SelectOption.objects.get(value="D").id,
"value": "D",
},
{
"color": "orange",
"id": SelectOption.objects.get(value="C").id,
"value": "C",
},
{
"color": "green",
"id": SelectOption.objects.get(value="E").id,
"value": "E",
},
],
"text": "text",
"url": "https://www.google.com",
}

View file

@ -224,9 +224,10 @@ def test_can_export_every_interesting_different_field_to_csv(
"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,phone_number\r\n"
"decimal_link_row,file_link_row,file,single_select,multiple_select,"
"phone_number\r\n"
"1,,,,,,,,,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,,,,,,\r\n"
"01/02/2021 13:00,01/02/2021,02/01/2021 13:00,02/01/2021,,,,,,,\r\n"
"2,text,long_text,https://www.google.com,test@example.com,-1,1,-1.2,1.2,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,"
@ -236,7 +237,7 @@ def test_can_export_every_interesting_different_field_to_csv(
'.txt,unnamed row 2",'
'"visible_name=a.txt url=http://localhost:8000/media/user_files/hashed_name.txt'
',visible_name=b.txt url=http://localhost:8000/media/user_files/other_name.txt"'
",A,+4412345678\r\n"
',A,"D,C,E",+4412345678\r\n'
)
assert expected == contents

View file

@ -506,6 +506,7 @@ def test_human_readable_values(data_fixture):
"positive_decimal": "",
"positive_int": "",
"single_select": "",
"multiple_select": "",
"text": "",
"url": "",
}
@ -535,6 +536,7 @@ def test_human_readable_values(data_fixture):
"positive_decimal": "1.2",
"positive_int": "1",
"single_select": "A",
"multiple_select": "D, C, E",
"text": "text",
"url": "https://www.google.com",
}

File diff suppressed because it is too large Load diff

View file

@ -273,13 +273,31 @@ def test_contains_filter_type(data_fixture):
number_negative=True,
number_decimal_places=2,
)
field_handler = FieldHandler()
single_select_field = data_fixture.create_single_select_field(table=table)
multiple_select_field = data_fixture.create_multiple_select_field(table=table)
multiple_select_field = field_handler.create_field(
user=user,
table=table,
name="Multiple Select",
type_name="multiple_select",
select_options=[
{"value": "CC", "color": "blue"},
{"value": "DC", "color": "blue"},
],
)
option_a = data_fixture.create_select_option(
field=single_select_field, value="AC", color="blue"
)
option_b = data_fixture.create_select_option(
field=single_select_field, value="BC", color="red"
)
option_c = data_fixture.create_select_option(
field=multiple_select_field, value="CE", color="green"
)
option_d = data_fixture.create_select_option(
field=multiple_select_field, value="DE", color="yellow"
)
handler = ViewHandler()
model = table.get_model()
@ -293,6 +311,7 @@ def test_contains_filter_type(data_fixture):
f"field_{single_select_field.id}": option_a,
}
)
getattr(row, f"field_{multiple_select_field.id}").set([option_c.id, option_d.id])
model.objects.create(
**{
f"field_{text_field.id}": "",
@ -312,6 +331,7 @@ def test_contains_filter_type(data_fixture):
f"field_{single_select_field.id}": option_b,
}
)
getattr(row_3, f"field_{multiple_select_field.id}").set([option_c.id])
view_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="contains", value="john"
@ -422,6 +442,27 @@ def test_contains_filter_type(data_fixture):
assert row.id in ids
assert row_3.id in ids
view_filter.field = multiple_select_field
view_filter.value = "C"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row.id in ids
assert row_3.id in ids
view_filter.field = multiple_select_field
view_filter.value = ""
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
view_filter.field = multiple_select_field
view_filter.value = "D"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row.id in ids
@pytest.mark.django_db
def test_contains_not_filter_type(data_fixture):
@ -440,12 +481,19 @@ def test_contains_not_filter_type(data_fixture):
number_decimal_places=2,
)
single_select_field = data_fixture.create_single_select_field(table=table)
multiple_select_field = data_fixture.create_multiple_select_field(table=table)
option_a = data_fixture.create_select_option(
field=single_select_field, value="AC", color="blue"
)
option_b = data_fixture.create_select_option(
field=single_select_field, value="BC", color="red"
)
option_c = data_fixture.create_select_option(
field=multiple_select_field, value="CE", color="green"
)
option_d = data_fixture.create_select_option(
field=multiple_select_field, value="DE", color="yellow"
)
handler = ViewHandler()
model = table.get_model()
@ -459,6 +507,8 @@ def test_contains_not_filter_type(data_fixture):
f"field_{single_select_field.id}": option_a,
}
)
getattr(row, f"field_{multiple_select_field.id}").set([option_c.id, option_d.id])
row_2 = model.objects.create(
**{
f"field_{text_field.id}": "",
@ -468,6 +518,7 @@ def test_contains_not_filter_type(data_fixture):
f"field_{single_select_field.id}": None,
}
)
row_3 = model.objects.create(
**{
f"field_{text_field.id}": "This is a test field.",
@ -478,6 +529,7 @@ def test_contains_not_filter_type(data_fixture):
f"field_{single_select_field.id}": option_b,
}
)
getattr(row_3, f"field_{multiple_select_field.id}").set([option_d.id])
view_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="contains_not", value="john"
@ -584,6 +636,27 @@ def test_contains_not_filter_type(data_fixture):
assert len(ids) == 1
assert row_2.id in ids
view_filter.field = multiple_select_field
view_filter.value = ""
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
view_filter.field = multiple_select_field
view_filter.value = "D"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row_2.id in ids
view_filter.field = multiple_select_field
view_filter.value = "C"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_2.id in ids
assert row_3.id in ids
@pytest.mark.django_db
def test_single_select_equal_filter_type(data_fixture):
@ -2048,6 +2121,10 @@ def test_empty_filter_type(data_fixture):
single_select_field = data_fixture.create_single_select_field(table=table)
option_1 = data_fixture.create_select_option(field=single_select_field)
multiple_select_field = data_fixture.create_multiple_select_field(table=table)
option_2 = data_fixture.create_select_option(field=multiple_select_field)
option_3 = data_fixture.create_select_option(field=multiple_select_field)
tmp_table = data_fixture.create_database_table(database=table.database)
tmp_field = data_fixture.create_text_field(table=tmp_table, primary=True)
link_row_field = FieldHandler().create_field(
@ -2092,6 +2169,7 @@ def test_empty_filter_type(data_fixture):
}
)
getattr(row_2, f"field_{link_row_field.id}").add(tmp_row.id)
getattr(row_2, f"field_{multiple_select_field.id}").add(option_2.id)
row_3 = model.objects.create(
**{
f"field_{text_field.id}": " ",
@ -2111,6 +2189,7 @@ def test_empty_filter_type(data_fixture):
}
)
getattr(row_3, f"field_{link_row_field.id}").add(tmp_row.id)
getattr(row_3, f"field_{multiple_select_field.id}").add(option_2.id, option_3.id)
view_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="empty", value=""
@ -2153,6 +2232,10 @@ def test_empty_filter_type(data_fixture):
view_filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row.id
view_filter.field = multiple_select_field
view_filter.save()
assert handler.apply_filters(grid_view, model.objects.all()).get().id == row.id
@pytest.mark.django_db
def test_not_empty_filter_type(data_fixture):
@ -2174,6 +2257,10 @@ def test_not_empty_filter_type(data_fixture):
single_select_field = data_fixture.create_single_select_field(table=table)
option_1 = data_fixture.create_select_option(field=single_select_field)
multiple_select_field = data_fixture.create_multiple_select_field(table=table)
option_2 = data_fixture.create_select_option(field=multiple_select_field)
option_3 = data_fixture.create_select_option(field=multiple_select_field)
tmp_table = data_fixture.create_database_table(database=table.database)
tmp_field = data_fixture.create_text_field(table=tmp_table, primary=True)
link_row_field = FieldHandler().create_field(
@ -2218,6 +2305,8 @@ def test_not_empty_filter_type(data_fixture):
}
)
getattr(row_2, f"field_{link_row_field.id}").add(tmp_row.id)
getattr(row_2, f"field_{multiple_select_field.id}").add(option_2.id)
getattr(row_2, f"field_{multiple_select_field.id}").add(option_3.id)
view_filter = data_fixture.create_view_filter(
view=grid_view, field=text_field, type="not_empty", value=""
@ -2637,6 +2726,26 @@ def test_link_row_has_filter_type(data_fixture):
assert row_3.id in ids
assert row_with_all_relations.id in ids
# Chaining filters should also work
# creating a second filter for the same field
data_fixture.create_view_filter(
view=grid_view,
field=link_row_field,
type="link_row_has",
value=f"{related_row_1.id}",
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row_with_all_relations.id in ids
# Changing the view to use "OR" for multiple filters
handler.update_view(user=user, view=grid_view, filter_type="OR")
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
assert row_1.id in ids
assert row_3.id in ids
assert row_with_all_relations.id in ids
@pytest.mark.django_db
def test_link_row_has_not_filter_type(data_fixture):
@ -2775,3 +2884,302 @@ def test_link_row_has_not_filter_type(data_fixture):
assert len(ids) == 3
assert row_3.id not in ids
assert row_with_all_relations.id not in ids
# Chaining filters should also work
# creating a second filter for the same field
data_fixture.create_view_filter(
view=grid_view,
field=link_row_field,
type="link_row_has_not",
value=f"{related_row_1.id}",
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_2.id in ids
# Changing the view to use "OR" for multiple filters
handler.update_view(user=user, view=grid_view, filter_type="OR")
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 4
assert row_1.id in ids
assert row_2.id in ids
assert row_3.id in ids
assert row_with_all_relations.id not in ids
@pytest.mark.django_db
def test_multiple_select_has_filter_type(data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(database=database)
grid_view = data_fixture.create_grid_view(table=table)
field_handler = FieldHandler()
multiple_select_field = field_handler.create_field(
user=user,
table=table,
type_name="multiple_select",
name="Multi Select",
select_options=[
{"value": "Option 1", "color": "blue"},
{"value": "Option 2", "color": "blue"},
{"value": "Option 3", "color": "red"},
],
)
row_handler = RowHandler()
model = table.get_model()
select_options = multiple_select_field.select_options.all()
row_1 = row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [
select_options[0].id,
select_options[1].id,
],
},
)
row_2 = row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [
select_options[1].id,
select_options[2].id,
],
},
)
row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [],
},
)
row_with_all_select_options = row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [
select_options[0].id,
select_options[1].id,
select_options[2].id,
],
},
)
handler = ViewHandler()
view_filter = data_fixture.create_view_filter(
view=grid_view,
field=multiple_select_field,
type="multiple_select_has",
value=f"",
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 4
view_filter.value = "not_number"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 4
view_filter.value = "-1"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 0
view_filter.value = f"{select_options[0].id}"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_1.id in ids
assert row_with_all_select_options.id in ids
view_filter.value = f"{select_options[1].id}"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
assert row_1.id in ids
assert row_2.id in ids
assert row_with_all_select_options.id in ids
view_filter.value = f"{select_options[2].id}"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_2.id in ids
assert row_with_all_select_options.id in ids
# chaining filters should also work
view_filter.value = f"{select_options[2].id}"
view_filter.save()
# creating a second filter for the same field
data_fixture.create_view_filter(
view=grid_view,
field=multiple_select_field,
type="multiple_select_has",
value=f"{select_options[1].id}",
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_2.id in ids
assert row_with_all_select_options.id in ids
# Changing the view to use "OR" for multiple filters
handler.update_view(user=user, view=grid_view, filter_type="OR")
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 3
assert row_1.id in ids
assert row_2.id in ids
assert row_with_all_select_options.id in ids
@pytest.mark.django_db
def test_multiple_select_has_filter_type_export_import():
view_filter_type = view_filter_type_registry.get("multiple_select_has")
id_mapping = {"database_field_select_options": {1: 2}}
assert view_filter_type.get_export_serialized_value("1") == "1"
assert view_filter_type.set_import_serialized_value("1", id_mapping) == "2"
assert view_filter_type.set_import_serialized_value("", id_mapping) == ""
assert view_filter_type.set_import_serialized_value("wrong", id_mapping) == ""
@pytest.mark.django_db
def test_multiple_select_has_not_filter_type(data_fixture):
user = data_fixture.create_user()
database = data_fixture.create_database_application(user=user)
table = data_fixture.create_database_table(database=database)
grid_view = data_fixture.create_grid_view(table=table)
field_handler = FieldHandler()
multiple_select_field = field_handler.create_field(
user=user,
table=table,
type_name="multiple_select",
name="Multi Select",
select_options=[
{"value": "Option 1", "color": "blue"},
{"value": "Option 2", "color": "blue"},
{"value": "Option 3", "color": "red"},
],
)
row_handler = RowHandler()
model = table.get_model()
select_options = multiple_select_field.select_options.all()
row_1 = row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [
select_options[0].id,
select_options[1].id,
],
},
)
row_2 = row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [
select_options[1].id,
select_options[2].id,
],
},
)
row_3 = row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [],
},
)
row_handler.create_row(
user=user,
table=table,
model=model,
values={
f"field_{multiple_select_field.id}": [
select_options[0].id,
select_options[1].id,
select_options[2].id,
],
},
)
handler = ViewHandler()
view_filter = data_fixture.create_view_filter(
view=grid_view,
field=multiple_select_field,
type="multiple_select_has_not",
value=f"",
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 4
view_filter.value = "not_number"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 4
view_filter.value = "-1"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 4
view_filter.value = f"{select_options[0].id}"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_2.id in ids
assert row_3.id in ids
view_filter.value = f"{select_options[1].id}"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row_3.id in ids
view_filter.value = f"{select_options[2].id}"
view_filter.save()
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_1.id in ids
assert row_3.id in ids
# chaining filters should also work
view_filter.value = f"{select_options[2].id}"
view_filter.save()
# creating a second filter for the same field
data_fixture.create_view_filter(
view=grid_view,
field=multiple_select_field,
type="multiple_select_has_not",
value=f"{select_options[1].id}",
)
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 1
assert row_3.id in ids
# Changing the view to use "OR" for multiple filters
handler.update_view(user=user, view=grid_view, filter_type="OR")
ids = [r.id for r in handler.apply_filters(grid_view, model.objects.all()).all()]
assert len(ids) == 2
assert row_1.id in ids
assert row_3.id in ids

View file

@ -10,6 +10,7 @@ from .view import ViewFixtures
from .field import FieldFixtures
from .token import TokenFixtures
from .template import TemplateFixtures
from .row import RowFixture
class Fixtures(
@ -23,5 +24,6 @@ class Fixtures(
FieldFixtures,
TokenFixtures,
TemplateFixtures,
RowFixture,
):
fake = Faker()

View file

@ -1,6 +1,7 @@
from django.db import connection
from baserow.contrib.database.fields.models import (
MultipleSelectField,
TextField,
LongTextField,
NumberField,
@ -179,6 +180,23 @@ class FieldFixtures:
return field
def create_multiple_select_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 = MultipleSelectField.objects.create(**kwargs)
if create_field:
self.create_model_field(kwargs["table"], field)
return field
def create_url_field(self, user=None, create_field=True, **kwargs):
if "table" not in kwargs:
kwargs["table"] = self.create_database_table(user=user)

55
backend/tests/fixtures/row.py vendored Normal file
View file

@ -0,0 +1,55 @@
from baserow.contrib.database.rows.handler import RowHandler
class RowFixture:
def create_row_for_many_to_many_field(
self, table, field, values, user, model=None, **kwargs
):
"""
This is a helper function for creating a row with a many-to-many field that
preserves the order of the elements that are being passed in as a list. This is
done by creating the row with the first element in the list and successively
updating the row for each additional element in the list, mimicking how the
relationships would be added when using the frontend.
Iteration steps:
Example list: [1, 2, 3]
First = create the row with: [1]
Second = update the row with: [1, 2]
Final = update the row with: [1, 2, 3]
"""
field_id = f"field_{field.id}"
row_handler = RowHandler()
if model is None:
model = table.get_model()
# If the values list is empty, we create an empty row and return that row.
if len(values) == 0:
return row_handler.create_row(
user=user, table=table, model=model, values={field_id: values}
)
row = None
for index, value in enumerate(values):
values_to_update = values[: index + 1]
if index == 0:
row = row_handler.create_row(
user=user,
table=table,
model=model,
values={field_id: values_to_update},
)
else:
row = row_handler.update_row(
user=user,
table=table,
model=model,
row_id=row.id,
values={field_id: values_to_update},
)
return row

View file

@ -127,6 +127,7 @@ def setup_interesting_test_table(data_fixture):
},
],
"single_select": SelectOption.objects.get(value="A"),
"multiple_select": None,
"phone_number": "+4412345678",
}
@ -221,6 +222,17 @@ def setup_interesting_test_table(data_fixture):
getattr(row, f"field_{name_to_field_id['file_link_row']}").add(
linked_row_7.id, linked_row_8.id
)
# multiple select
getattr(row, f"field_{name_to_field_id['multiple_select']}").add(
SelectOption.objects.get(value="D").id
)
getattr(row, f"field_{name_to_field_id['multiple_select']}").add(
SelectOption.objects.get(value="C").id
)
getattr(row, f"field_{name_to_field_id['multiple_select']}").add(
SelectOption.objects.get(value="E").id
)
return table, user, row, blank_row

View file

@ -23,6 +23,7 @@
* Fixed bug where the backend would fail hard when an invalid integer was provided as
'before_id' when moving a row by introducing a decorator to validate query parameters.
* Fixed bug where copying a cell containing a null value resulted in an error.
* Added "Multiple Select" field type.
## Released (2021-08-11)

View file

@ -57,6 +57,7 @@ def test_can_export_every_interesting_different_field_to_json(
"file_link_row": [],
"file": [],
"single_select": "",
"multiple_select": [],
"phone_number": ""
},
{
@ -112,6 +113,11 @@ def test_can_export_every_interesting_different_field_to_json(
}
],
"single_select": "A",
"multiple_select": [
"D",
"C",
"E"
],
"phone_number": "+4412345678"
}
]
@ -189,6 +195,7 @@ def test_can_export_every_interesting_different_field_to_xml(
<file-link-row/>
<file/>
<single-select/>
<multiple-select/>
<phone-number/>
</row>
<row>
@ -246,6 +253,11 @@ def test_can_export_every_interesting_different_field_to_xml(
</item>
</file>
<single-select>A</single-select>
<multiple-select>
<item>D</item>
<item>C</item>
<item>E</item>
</multiple-select>
<phone-number>+4412345678</phone-number>
</row>
</rows>

View file

@ -20,7 +20,7 @@
@import 'fields/date';
@import 'fields/link_row';
@import 'fields/file';
@import 'fields/single_select';
@import 'fields/multiple_select';
@import 'views/grid';
@import 'views/grid/text';
@import 'views/grid/long_text';
@ -30,6 +30,8 @@
@import 'views/grid/link_row';
@import 'views/grid/file';
@import 'views/grid/single_select';
@import 'views/grid/multiple_select';
@import 'views/grid/many_to_many';
@import 'views/form';
@import 'box_page';
@import 'loading';
@ -57,6 +59,7 @@
@import 'file_field_modal';
@import 'select_options';
@import 'select_options_listing';
@import 'select_options_dropdown';
@import 'color_select';
@import 'group_member';
@import 'separator';

View file

@ -0,0 +1,34 @@
.field-multiple-select__items {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.field-multiple-select__item {
margin: 6px 0 5px;
@include select-option-style(flex, true);
&:not(:last-child) {
margin-right: 5px;
}
}
.field-multiple-select__name {
@extend %ellipsis;
max-width: 200px;
}
.field-multiple-select__remove {
color: $color-primary-900;
margin-left: 5px;
font-size: 11px;
padding: 0 2px;
&:hover {
color: $color-neutral-500;
}
}

View file

@ -1,31 +0,0 @@
.field-single-select__dropdown-item.hover {
background-color: $color-neutral-100;
}
.field-single-select__dropdown-link {
display: flex;
align-items: center;
padding: 0 10px;
height: 32px;
&:hover {
text-decoration: none;
}
}
.field-single-select__dropdown-selected {
display: flex;
align-items: center;
}
.field-single-select__dropdown-option {
@extend %ellipsis;
display: inline-block;
color: $color-primary-900;
border-radius: 20px;
padding: 0 10px;
max-width: 100%;
@include fixed-height(20px, 12px);
}

View file

@ -0,0 +1,23 @@
.select-options__dropdown-item.hover {
background-color: $color-neutral-100;
}
.select-options__dropdown-link {
display: flex;
align-items: center;
padding: 0 10px;
height: 32px;
&:hover {
text-decoration: none;
}
}
.select-options__dropdown-selected {
display: flex;
align-items: center;
}
.select-options__dropdown-option {
@include select-option-style(inline-block, false);
}

View file

@ -1,85 +1,3 @@
.grid-field-link-row__cell.active {
bottom: auto;
right: auto;
height: auto;
min-width: calc(100% + 4px);
min-height: calc(100% + 4px);
}
.grid-field-link-row__list {
overflow: hidden;
display: flex;
flex-flow: row;
flex-wrap: nowrap;
list-style: none;
margin: 0;
padding: 0 4px;
.grid-field-link-row__cell.active & {
height: auto;
flex-wrap: wrap;
}
}
.grid-field-link-row__item {
white-space: nowrap;
margin: 5px 4px;
padding: 0 5px;
background-color: $color-neutral-100;
border-radius: 3px;
display: flex;
@include fixed-height(22px, 13px);
.grid-field-link-row__cell.active & {
background-color: $color-primary-100;
.grid-view__column--matches-search & {
background-color: $color-primary-200;
}
}
&.grid-field-link-row__item--link {
display: none;
font-size: 11px;
color: $color-primary-900;
&:hover {
background-color: $color-primary-200;
.grid-view__column--matches-search & {
background-color: $color-primary-300;
}
}
.grid-field-link-row__cell.active & {
display: block;
}
}
}
.grid-field-link-row__name {
@extend %ellipsis;
max-width: 140px;
&.grid-field-link-row__name--unnamed {
color: $color-neutral-600;
}
}
.grid-field-link-row__remove {
display: none;
color: $color-primary-900;
margin-left: 5px;
font-size: 11px;
padding: 0 2px;
&:hover {
color: $color-neutral-500;
}
.grid-field-link-row__cell.active & {
display: block;
}
.grid-field-link-row__unnamed {
color: $color-neutral-600;
}

View file

@ -0,0 +1,81 @@
.grid-field-many-to-many__cell.active {
bottom: auto;
right: auto;
height: auto;
min-width: calc(100% + 4px);
min-height: calc(100% + 4px);
}
.grid-field-many-to-many__list {
overflow: hidden;
display: flex;
flex-flow: row;
flex-wrap: nowrap;
list-style: none;
margin: 0;
padding: 0 4px;
.grid-field-many-to-many__cell.active & {
height: auto;
flex-wrap: wrap;
}
}
.grid-field-many-to-many__item {
white-space: nowrap;
margin: 5px 4px;
padding: 0 5px;
background-color: $color-neutral-100;
border-radius: 3px;
display: flex;
@include fixed-height(22px, 13px);
.grid-field-many-to-many__cell.active & {
background-color: $color-primary-100;
.grid-view__column--matches-search & {
background-color: $color-primary-200;
}
}
&.grid-field-many-to-many__item--link {
display: none;
font-size: 11px;
color: $color-primary-900;
&:hover {
background-color: $color-primary-200;
.grid-view__column--matches-search & {
background-color: $color-primary-300;
}
}
.grid-field-many-to-many__cell.active & {
display: block;
}
}
}
.grid-field-many-to-many__name {
@extend %ellipsis;
max-width: 140px;
}
.grid-field-many-to-many__remove {
display: none;
color: $color-primary-900;
margin-left: 5px;
font-size: 11px;
padding: 0 2px;
&:hover {
color: $color-neutral-500;
}
.grid-field-many-to-many__cell.active & {
display: block;
}
}

View file

@ -0,0 +1,11 @@
.grid-field-multiple-select__item {
white-space: nowrap;
margin: 6px 4px 4px 4px;
overflow: unset;
@include select-option-style(flex, true);
.grid-view__column--matches-search .grid-field-many-to-many__cell.active & {
background-color: $color-primary-200;
}
}

View file

@ -16,22 +16,9 @@
}
.grid-field-single-select__option {
@extend %ellipsis;
display: inline-block;
color: $color-primary-900;
border-radius: 20px;
padding: 0 10px;
max-width: 100%;
margin-top: 6px;
&.background-color--light-blue {
.grid-view__column--matches-search & {
box-shadow: 0 0 2px 1px rgba($black, 0.16);
}
}
@include fixed-height(20px, 12px);
@include select-option-style(inline-block, true);
}
.grid-field-single-select__icon {

View file

@ -2,3 +2,4 @@
@import 'alert';
@import 'button';
@import 'filters';
@import 'select_option';

View file

@ -0,0 +1,19 @@
@mixin select-option-style($display, $include-shadow-if-bg-light-blue: false) {
@extend %ellipsis;
display: $display;
color: $color-primary-900;
border-radius: 20px;
padding: 0 10px;
max-width: 100%;
@if $include-shadow-if-bg-light-blue {
&.background-color--light-blue {
.grid-view__column--matches-search & {
box-shadow: 0 0 2px 1px rgba($black, 0.16);
}
}
}
@include fixed-height(20px, 12px);
}

View file

@ -9,12 +9,12 @@
>
<a
v-if="showInput"
class="field-single-select__dropdown-selected dropdown__selected"
class="select-options__dropdown-selected dropdown__selected"
@click="show()"
>
<div
v-if="hasValue()"
class="field-single-select__dropdown-option"
class="select-options__dropdown-option"
:class="'background-color--' + selectedColor"
>
{{ selectedName }}
@ -39,18 +39,19 @@
v-auto-overflow-scroll
class="select__items"
>
<FieldSingleSelectDropdownItem
<FieldSelectOptionsDropdownItem
v-if="showEmptyValue"
:name="''"
:value="null"
:color="''"
></FieldSingleSelectDropdownItem>
<FieldSingleSelectDropdownItem
></FieldSelectOptionsDropdownItem>
<FieldSelectOptionsDropdownItem
v-for="option in options"
:key="option.id"
:name="option.value"
:value="option.id"
:color="option.color"
></FieldSingleSelectDropdownItem>
></FieldSelectOptionsDropdownItem>
</ul>
<template v-if="canCreateOption">
<div class="select__description">
@ -73,11 +74,11 @@
<script>
import dropdown from '@baserow/modules/core/mixins/dropdown'
import FieldSingleSelectDropdownItem from '@baserow/modules/database/components/field/FieldSingleSelectDropdownItem'
import FieldSelectOptionsDropdownItem from '@baserow/modules/database/components/field/FieldSelectOptionsDropdownItem'
export default {
name: 'FieldSingleSelectDropdown',
components: { FieldSingleSelectDropdownItem },
name: 'FieldSelectOptionsDropdown',
components: { FieldSelectOptionsDropdownItem },
mixins: [dropdown],
props: {
options: {
@ -89,6 +90,11 @@ export default {
required: false,
default: false,
},
showEmptyValue: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {

View file

@ -1,6 +1,6 @@
<template>
<li
class="field-single-select__dropdown-item"
class="select-options__dropdown-item"
:class="{
hidden: !isVisible(query),
active: isActive(value),
@ -9,12 +9,12 @@
}"
>
<a
class="field-single-select__dropdown-link"
class="select-options__dropdown-link"
@click="select(value, disabled)"
@mousemove="hover(value, disabled)"
>
<div
class="field-single-select__dropdown-option"
class="select-options__dropdown-option"
:class="'background-color--' + color"
>
{{ name }}
@ -27,7 +27,7 @@
import dropdownItem from '@baserow/modules/core/mixins/dropdownItem'
export default {
name: 'FieldSingleSelectDropdownItem',
name: 'FieldSelectOptionsDropdownItem',
mixins: [dropdownItem],
props: {
color: {

View file

@ -20,7 +20,7 @@ import fieldSubForm from '@baserow/modules/database/mixins/fieldSubForm'
import FieldSelectOptions from '@baserow/modules/database/components/field/FieldSelectOptions'
export default {
name: 'FieldNumberSubForm',
name: 'FieldSelectOptionsSubForm',
components: { FieldSelectOptions },
mixins: [form, fieldSubForm],
data() {

View file

@ -0,0 +1,58 @@
<template>
<div class="control__elements">
<ul class="field-multiple-select__items">
<li
v-for="item in value"
:key="item.id"
class="field-multiple-select__item"
:class="'background-color--' + item.color"
>
<div class="field-multiple-select__name">
{{ item.value }}
</div>
<a
v-if="!readOnly"
class="field-multiple-select__remove"
@click.prevent="removeValue($event, value, item.id)"
>
<i class="fas fa-times"></i>
</a>
</li>
</ul>
<a
v-if="!readOnly"
ref="dropdownLink"
class="add"
@click.prevent="toggleDropdown()"
>
<i class="fas fa-plus add__icon"></i>
Add another option
</a>
<FieldSelectOptionsDropdown
ref="dropdown"
:options="availableSelectOptions"
:allow-create-option="true"
:disabled="readOnly"
:show-input="false"
:show-empty-value="false"
:class="{ 'dropdown--error': touched && !valid }"
@input="updateValue($event, value)"
@create-option="createOption($event)"
@hide="touch()"
></FieldSelectOptionsDropdown>
<div v-show="touched && !valid" class="error">
{{ error }}
</div>
</div>
</template>
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import selectOptions from '@baserow/modules/database/mixins/selectOptions'
import multipleSelectField from '@baserow/modules/database/mixins/multipleSelectField'
export default {
name: 'RowEditFieldMultipleSelect',
mixins: [rowEditField, selectOptions, multipleSelectField],
}
</script>

View file

@ -1,6 +1,6 @@
<template>
<div class="control__elements">
<FieldSingleSelectDropdown
<FieldSelectOptionsDropdown
:value="valueId"
:options="field.select_options"
:allow-create-option="true"
@ -9,7 +9,7 @@
@input="updateValue($event, value)"
@create-option="createOption($event)"
@hide="touch()"
></FieldSingleSelectDropdown>
></FieldSelectOptionsDropdown>
<div v-show="touched && !valid" class="error">
{{ error }}
</div>
@ -18,15 +18,11 @@
<script>
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import selectOptions from '@baserow/modules/database/mixins/selectOptions'
import singleSelectField from '@baserow/modules/database/mixins/singleSelectField'
export default {
name: 'RowEditFieldSingleSelectVue',
mixins: [rowEditField, singleSelectField],
methods: {
updateValue(...args) {
singleSelectField.methods.updateValue.call(this, ...args)
},
},
name: 'RowEditFieldSingleSelect',
mixins: [rowEditField, selectOptions, singleSelectField],
}
</script>

View file

@ -1,20 +1,20 @@
<template>
<FieldSingleSelectDropdown
<FieldSelectOptionsDropdown
:value="copy"
:options="field.select_options"
:disabled="readOnly"
class="dropdown--floating filters__value-dropdown dropdown--tiny"
@input="input"
></FieldSingleSelectDropdown>
></FieldSelectOptionsDropdown>
</template>
<script>
import FieldSingleSelectDropdown from '@baserow/modules/database/components/field/FieldSingleSelectDropdown'
import FieldSelectOptionsDropdown from '@baserow/modules/database/components/field/FieldSelectOptionsDropdown'
import viewFilter from '@baserow/modules/database/mixins/viewFilter'
export default {
name: 'ViewFilterTypeSelectOptions',
components: { FieldSingleSelectDropdown },
components: { FieldSelectOptionsDropdown },
mixins: [viewFilter],
computed: {
copy() {

View file

@ -1,15 +1,15 @@
<template functional>
<div class="grid-view__cell grid-field-link-row__cell">
<div class="grid-field-link-row__list">
<div class="grid-view__cell grid-field-many-to-many__cell">
<div class="grid-field-many-to-many__list">
<div
v-for="item in props.value"
:key="item.id"
class="grid-field-link-row__item"
class="grid-field-many-to-many__item"
>
<span
class="grid-field-link-row__name"
class="grid-field-many-to-many__name"
:class="{
'grid-field-link-row__name--unnamed':
'grid-field-link-row__unnamed':
item.value === null || item.value === '',
}"
>

View file

@ -0,0 +1,16 @@
<template functional>
<div ref="cell" class="grid-view__cell grid-field-many-to-many__cell">
<div class="grid-field-many-to-many__list">
<div
v-for="item in props.value"
:key="item.id"
class="grid-field-multiple-select__item"
:class="'background-color--' + item.color"
>
<div v-if="props.value" class="grid-field-many-to-many__name">
{{ item.value }}
</div>
</div>
</div>
</div>
</template>

View file

@ -1,15 +1,15 @@
<template>
<div class="grid-view__cell grid-field-link-row__cell active">
<div class="grid-field-link-row__list">
<div class="grid-view__cell grid-field-many-to-many__cell active">
<div class="grid-field-many-to-many__list">
<div
v-for="item in value"
:key="item.id"
class="grid-field-link-row__item"
class="grid-field-many-to-many__item"
>
<span
class="grid-field-link-row__name"
class="grid-field-many-to-many__name"
:class="{
'grid-field-link-row__name--unnamed':
'grid-field-link-row__unnamed':
item.value === null || item.value === '',
}"
>
@ -17,7 +17,7 @@
</span>
<a
v-if="!readOnly"
class="grid-field-link-row__remove"
class="grid-field-many-to-many__remove"
@click.prevent="removeValue($event, value, item.id)"
>
<i class="fas fa-times"></i>
@ -25,7 +25,9 @@
</div>
<a
v-if="!readOnly"
class="grid-field-link-row__item grid-field-link-row__item--link"
class="
grid-field-many-to-many__item grid-field-many-to-many__item--link
"
@click.prevent="showModal()"
>
<i class="fas fa-plus"></i>

View file

@ -0,0 +1,60 @@
<template>
<div ref="cell" class="grid-view__cell grid-field-many-to-many__cell active">
<div ref="dropdownLink" class="grid-field-many-to-many__list">
<div
v-for="item in value"
:key="item.id"
class="grid-field-multiple-select__item"
:class="'background-color--' + item.color"
>
<div class="grid-field-many-to-many__name">
{{ item.value }}
</div>
<a
v-if="!readOnly"
class="grid-field-many-to-many__remove"
@click.prevent="removeValue($event, value, item.id)"
>
<i class="fas fa-times"></i>
</a>
</div>
<a
v-if="!readOnly"
class="
grid-field-many-to-many__item grid-field-many-to-many__item--link
"
@click.prevent="toggleDropdown()"
>
<i class="fas fa-plus"></i>
</a>
</div>
<FieldSelectOptionsDropdown
v-if="!readOnly"
ref="dropdown"
:options="availableSelectOptions"
:show-input="false"
:show-empty-value="false"
:allow-create-option="true"
class="dropdown--floating grid-field-single-select__dropdown"
@show="editing = true"
@hide="editing = false"
@input="updateValue($event, value)"
@create-option="createOption($event)"
></FieldSelectOptionsDropdown>
</div>
</template>
<script>
import gridField from '@baserow/modules/database/mixins/gridField'
import selectOptions from '@baserow/modules/database/mixins/selectOptions'
import multipleSelectField from '@baserow/modules/database/mixins/multipleSelectField'
export default {
mixins: [gridField, selectOptions, multipleSelectField],
data() {
return {
editing: false,
}
},
}
</script>

View file

@ -18,7 +18,7 @@
class="fa fa-caret-down grid-field-single-select__icon"
></i>
</div>
<FieldSingleSelectDropdown
<FieldSelectOptionsDropdown
v-if="!readOnly"
ref="dropdown"
:value="valueId"
@ -30,77 +30,21 @@
@hide="editing = false"
@input="updateValue($event, value)"
@create-option="createOption($event)"
></FieldSingleSelectDropdown>
></FieldSelectOptionsDropdown>
</div>
</template>
<script>
import gridField from '@baserow/modules/database/mixins/gridField'
import { isPrintableUnicodeCharacterKeyPress } from '@baserow/modules/core/utils/events'
import selectOptions from '@baserow/modules/database/mixins/selectOptions'
import singleSelectField from '@baserow/modules/database/mixins/singleSelectField'
export default {
mixins: [gridField, singleSelectField],
mixins: [gridField, selectOptions, singleSelectField],
data() {
return {
editing: false,
}
},
methods: {
toggleDropdown(value, query) {
if (this.readOnly) {
return
}
this.$refs.dropdown.toggle(this.$refs.dropdownLink, value, query)
},
hideDropdown() {
this.$refs.dropdown.hide()
},
select() {
this.$el.keydownEvent = (event) => {
// If the tab or arrow keys are pressed we don't want to do anything because
// the GridViewField component will select the next field.
const ignoredKeys = [9, 37, 38, 39, 40]
if (ignoredKeys.includes(event.keyCode)) {
return
}
// When the escape key is pressed while editing the value we can hide the
// dropdown.
if (event.keyCode === 27 && this.editing) {
this.hideDropdown()
return
}
// When the enter key, any printable character or F2 is pressed when not editing
// the value we want to show the dropdown.
if (
!this.editing &&
(event.keyCode === 13 ||
isPrintableUnicodeCharacterKeyPress(event) ||
event.key === 'F2')
) {
this.toggleDropdown()
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
beforeUnSelect() {
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
canSelectNext() {
return !this.editing
},
canCopy() {
return !this.editing
},
canPaste() {
return !this.editing
},
canEmpty() {
return !this.editing
},
},
}
</script>

View file

@ -14,7 +14,7 @@ import FieldTextSubForm from '@baserow/modules/database/components/field/FieldTe
import FieldDateSubForm from '@baserow/modules/database/components/field/FieldDateSubForm'
import FieldCreatedOnLastModifiedSubForm from '@baserow/modules/database/components/field/FieldCreatedOnLastModifiedSubForm'
import FieldLinkRowSubForm from '@baserow/modules/database/components/field/FieldLinkRowSubForm'
import FieldSingleSelectSubForm from '@baserow/modules/database/components/field/FieldSingleSelectSubForm'
import FieldSelectOptionsSubForm from '@baserow/modules/database/components/field/FieldSelectOptionsSubForm'
import GridViewFieldText from '@baserow/modules/database/components/view/grid/fields/GridViewFieldText'
import GridViewFieldLongText from '@baserow/modules/database/components/view/grid/fields/GridViewFieldLongText'
@ -27,6 +27,7 @@ import GridViewFieldDate from '@baserow/modules/database/components/view/grid/fi
import GridViewFieldDateReadOnly from '@baserow/modules/database/components/view/grid/fields/GridViewFieldDateReadOnly'
import GridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/GridViewFieldFile'
import GridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/fields/GridViewFieldSingleSelect'
import GridViewFieldMultipleSelect from '@baserow/modules/database/components/view/grid/fields/GridViewFieldMultipleSelect'
import GridViewFieldPhoneNumber from '@baserow/modules/database/components/view/grid/fields/GridViewFieldPhoneNumber'
import FunctionalGridViewFieldText from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldText'
@ -37,6 +38,7 @@ import FunctionalGridViewFieldBoolean from '@baserow/modules/database/components
import FunctionalGridViewFieldDate from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldDate'
import FunctionalGridViewFieldFile from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldFile'
import FunctionalGridViewFieldSingleSelect from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldSingleSelect'
import FunctionalGridViewFieldMultipleSelect from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldMultipleSelect'
import FunctionalGridViewFieldPhoneNumber from '@baserow/modules/database/components/view/grid/fields/FunctionalGridViewFieldPhoneNumber'
import RowEditFieldText from '@baserow/modules/database/components/row/RowEditFieldText'
@ -50,6 +52,7 @@ import RowEditFieldDate from '@baserow/modules/database/components/row/RowEditFi
import RowEditFieldDateReadOnly from '@baserow/modules/database/components/row/RowEditFieldDateReadOnly'
import RowEditFieldFile from '@baserow/modules/database/components/row/RowEditFieldFile'
import RowEditFieldSingleSelect from '@baserow/modules/database/components/row/RowEditFieldSingleSelect'
import RowEditFieldMultipleSelect from '@baserow/modules/database/components/row/RowEditFieldMultipleSelect'
import RowEditFieldPhoneNumber from '@baserow/modules/database/components/row/RowEditFieldPhoneNumber'
import FormViewFieldLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldLinkRow'
@ -1434,7 +1437,7 @@ export class SingleSelectFieldType extends FieldType {
}
getFormComponent() {
return FieldSingleSelectSubForm
return FieldSelectOptionsSubForm
}
getGridViewFieldComponent() {
@ -1546,6 +1549,143 @@ export class SingleSelectFieldType extends FieldType {
}
}
export class MultipleSelectFieldType extends FieldType {
static getType() {
return 'multiple_select'
}
getIconClass() {
return 'list'
}
getName() {
return 'Multiple select'
}
getFormComponent() {
return FieldSelectOptionsSubForm
}
getGridViewFieldComponent() {
return GridViewFieldMultipleSelect
}
getFunctionalGridViewFieldComponent() {
return FunctionalGridViewFieldMultipleSelect
}
getRowEditFieldComponent() {
return RowEditFieldMultipleSelect
}
getSort(name, order) {
return (a, b) => {
const valuesA = a[name]
const valuesB = b[name]
const stringA =
valuesA.length > 0 ? valuesA.map((obj) => obj.value).join('') : ''
const stringB =
valuesB.length > 0 ? valuesB.map((obj) => obj.value).join('') : ''
return order === 'ASC'
? stringA.localeCompare(stringB)
: stringB.localeCompare(stringA)
}
}
prepareValueForUpdate(field, value) {
if (value === undefined || value === null) {
return []
}
return value.map((item) => item.id)
}
prepareValueForCopy(field, value) {
let returnValue
if (value === undefined || value === null) {
returnValue = []
}
returnValue = value
return JSON.stringify({
value: returnValue,
})
}
prepareValueForPaste(field, clipboardData) {
let values
try {
values = JSON.parse(clipboardData.getData('text'))
} catch (SyntaxError) {
return []
}
// We need to check whether the pasted select_options belong to this field.
const pastedIDs = values.value.map((obj) => obj.id)
const fieldSelectOptionIDs = field.select_options.map((obj) => obj.id)
const pastedIDsBelongToField = pastedIDs.some((id) =>
fieldSelectOptionIDs.includes(id)
)
if (pastedIDsBelongToField) {
return values.value
} else {
return []
}
}
toHumanReadableString(field, value) {
if (value === undefined || value === null || value === []) {
return ''
}
return value.map((item) => item.value).join(', ')
}
getDocsDataType() {
return 'array'
}
getDocsDescription(field) {
const options = field.select_options
.map(
(option) =>
// @TODO move this template to a component.
`<div class="select-options-listing">
<div class="select-options-listing__id">${option.id}</div>
<div class="select-options-listing__value background-color--${option.color}">${option.value}</div>
</div>
`
)
.join('\n')
return `
Accepts an array of integers each representing the chosen select option id or null if none is selected.
<br />
${options}
`
}
getDocsRequestExample() {
return [1]
}
getDocsResponseExample() {
return [
{
id: 1,
value: 'Option',
color: 'light-blue',
},
]
}
getContainsFilterFunction() {
return genericContainsFilter
}
getEmptyValue() {
return []
}
}
export class PhoneNumberFieldType extends FieldType {
static getType() {
return 'phone_number'

View file

@ -0,0 +1,40 @@
export default {
computed: {
availableSelectOptions() {
const ids = this.value.map((item) => item.id)
return this.field.select_options.filter((item) => !ids.includes(item.id))
},
},
methods: {
/**
* Removes the provided ID from the current values list and emits an update
* event with the new values list.
*/
removeValue(event, currentValues, itemIdToRemove) {
const vals = currentValues.filter((item) => item.id !== itemIdToRemove)
this.$emit('update', vals, currentValues)
},
/**
* Checks if the new value is a valid select_option id for the field and if
* so will add it to a new values list. If this new list of values is unequal
* to the old list of values an update event will be emitted which will result
* in an API call in order to persist the new value to the field.
*/
updateValue(newId, oldValue) {
if (!oldValue) {
oldValue = []
}
const newOption =
this.field.select_options.find((option) => option.id === newId) || null
const newValue = [...oldValue]
if (newOption) {
newValue.push(newOption)
}
if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
this.$emit('update', newValue, oldValue)
}
},
},
}

View file

@ -0,0 +1,89 @@
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 FieldSelectOptionsDropdown from '@baserow/modules/database/components/field/FieldSelectOptionsDropdown'
export default {
components: { FieldSelectOptionsDropdown },
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
* 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)],
})
try {
await this.$store.dispatch('field/update', {
field: this.field,
type: this.field.type,
values,
})
done(true)
} catch (error) {
notifyIf(error, 'field')
done(false)
}
},
toggleDropdown(value, query) {
if (this.readOnly) {
return
}
this.$refs.dropdown.toggle(this.$refs.dropdownLink, value, query)
},
hideDropdown() {
this.$refs.dropdown.hide()
},
select() {
this.$el.keydownEvent = (event) => {
// If the tab or arrow keys are pressed we don't want to do anything because
// the GridViewField component will select the next field.
const ignoredKeys = [9, 37, 38, 39, 40]
if (ignoredKeys.includes(event.keyCode)) {
return
}
// When the escape key is pressed while editing the value we can hide the
// dropdown.
if (event.keyCode === 27 && this.editing) {
this.hideDropdown()
return
}
// When the enter key, any printable character or F2 is pressed when not editing
// the value we want to show the dropdown.
if (
!this.editing &&
(event.keyCode === 13 ||
isPrintableUnicodeCharacterKeyPress(event) ||
event.key === 'F2')
) {
this.toggleDropdown()
}
}
document.body.addEventListener('keydown', this.$el.keydownEvent)
},
beforeUnSelect() {
document.body.removeEventListener('keydown', this.$el.keydownEvent)
},
canSelectNext() {
return !this.editing
},
canCopy() {
return !this.editing
},
canPaste() {
return !this.editing
},
canEmpty() {
return !this.editing
},
},
}

View file

@ -1,10 +1,4 @@
import { notifyIf } from '@baserow/modules/core/utils/error'
import { clone } from '@baserow/modules/core/utils/object'
import { colors } from '@baserow/modules/core/utils/colors'
import FieldSingleSelectDropdown from '@baserow/modules/database/components/field/FieldSingleSelectDropdown'
export default {
components: { FieldSingleSelectDropdown },
computed: {
valueId() {
return this.value && this.value !== null ? this.value.id : null
@ -23,29 +17,5 @@ export default {
this.$emit('update', newValue, oldValue)
}
},
/**
* 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
* 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)],
})
try {
await this.$store.dispatch('field/update', {
field: this.field,
type: this.field.type,
values,
})
done(true)
} catch (error) {
notifyIf(error, 'field')
done(false)
}
},
},
}

View file

@ -12,6 +12,7 @@ import {
LastModifiedFieldType,
FileFieldType,
SingleSelectFieldType,
MultipleSelectFieldType,
PhoneNumberFieldType,
CreatedOnFieldType,
} from '@baserow/modules/database/fieldTypes'
@ -38,6 +39,8 @@ import {
DateAfterViewFilterType,
LinkRowHasFilterType,
LinkRowHasNotFilterType,
MultipleSelectHasFilterType,
MultipleSelectHasNotFilterType,
} from '@baserow/modules/database/viewFilters'
import {
CSVImporterType,
@ -107,6 +110,11 @@ export default (context) => {
app.$registry.register('viewFilter', new BooleanViewFilterType(context))
app.$registry.register('viewFilter', new LinkRowHasFilterType(context))
app.$registry.register('viewFilter', new LinkRowHasNotFilterType(context))
app.$registry.register('viewFilter', new MultipleSelectHasFilterType(context))
app.$registry.register(
'viewFilter',
new MultipleSelectHasNotFilterType(context)
)
app.$registry.register('viewFilter', new EmptyViewFilterType(context))
app.$registry.register('viewFilter', new NotEmptyViewFilterType(context))
app.$registry.register('field', new TextFieldType(context))
@ -121,6 +129,7 @@ export default (context) => {
app.$registry.register('field', new EmailFieldType(context))
app.$registry.register('field', new FileFieldType(context))
app.$registry.register('field', new SingleSelectFieldType(context))
app.$registry.register('field', new MultipleSelectFieldType(context))
app.$registry.register('field', new PhoneNumberFieldType(context))
app.$registry.register('importer', new CSVImporterType(context))
app.$registry.register('importer', new PasteImporterType(context))

View file

@ -242,6 +242,7 @@ export class ContainsViewFilterType extends ViewFilterType {
'last_modified',
'created_on',
'single_select',
'multiple_select',
'number',
]
}
@ -276,6 +277,7 @@ export class ContainsNotViewFilterType extends ViewFilterType {
'last_modified',
'created_on',
'single_select',
'multiple_select',
'number',
]
}
@ -665,6 +667,68 @@ export class SingleSelectNotEqualViewFilterType extends ViewFilterType {
}
}
export class MultipleSelectHasFilterType extends ViewFilterType {
static getType() {
return 'multiple_select_has'
}
getName() {
return 'has'
}
getExample() {
return '1'
}
getInputComponent() {
return ViewFilterTypeSelectOptions
}
getCompatibleFieldTypes() {
return ['multiple_select']
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
return true
}
const filterValueId = parseInt(filterValue)
return rowValue.some((option) => option.id === filterValueId)
}
}
export class MultipleSelectHasNotFilterType extends ViewFilterType {
static getType() {
return 'multiple_select_has_not'
}
getName() {
return 'has not'
}
getExample() {
return '1'
}
getInputComponent() {
return ViewFilterTypeSelectOptions
}
getCompatibleFieldTypes() {
return ['multiple_select']
}
matches(rowValue, filterValue, field, fieldType) {
if (!isNumeric(filterValue)) {
return true
}
const filterValueId = parseInt(filterValue)
return !rowValue.some((option) => option.id === filterValueId)
}
}
export class BooleanViewFilterType extends ViewFilterType {
static getType() {
return 'boolean'
@ -792,6 +856,7 @@ export class EmptyViewFilterType extends ViewFilterType {
'link_row',
'file',
'single_select',
'multiple_select',
'phone_number',
]
}
@ -838,6 +903,7 @@ export class NotEmptyViewFilterType extends ViewFilterType {
'link_row',
'file',
'single_select',
'multiple_select',
'phone_number',
]
}

View file

@ -107,7 +107,7 @@ const mockedFields = {
id: 12,
name: 'single_select',
order: 12,
primary: true,
primary: false,
table_id: 42,
type: 'single_select',
select_options: [],
@ -120,6 +120,15 @@ const mockedFields = {
table_id: 42,
type: 'phone_number',
},
multiple_select: {
id: 14,
name: 'multiple_select',
order: 14,
primary: false,
table_id: 42,
type: 'multiple_select',
select_options: [],
},
}
const valuesToCall = [null, undefined]

View file

@ -0,0 +1,121 @@
import { MultipleSelectFieldType } from '@baserow/modules/database/fieldTypes'
import { firstBy } from 'thenby'
import { TestApp } from '@baserow/test/helpers/testApp'
const testTableData = [
{
id: 1,
order: '1.00000000000000000000',
field_272: 'Tesla',
field_275: [{ id: 146, value: 'C', color: 'red' }],
},
{
id: 2,
order: '2.00000000000000000000',
field_272: 'Amazon',
field_275: [{ id: 144, value: 'A', color: 'blue' }],
},
{
id: 3,
order: '3.00000000000000000000',
field_272: '',
field_275: [
{ id: 144, value: 'A', color: 'blue' },
{ id: 145, value: 'B', color: 'orange' },
],
},
{
id: 4,
order: '4.00000000000000000000',
field_272: '',
field_275: [
{ id: 144, value: 'A', color: 'blue' },
{ id: 145, value: 'B', color: 'orange' },
{ id: 146, value: 'C', color: 'red' },
],
},
{
id: 5,
order: '5.00000000000000000000',
field_272: '',
field_275: [
{ id: 149, value: 'F', color: 'light-gray' },
{ id: 148, value: 'E', color: 'dark-red' },
],
},
{
id: 6,
order: '6.00000000000000000000',
field_272: '',
field_275: [
{ id: 149, value: 'F', color: 'light-gray' },
{ id: 144, value: 'A', color: 'blue' },
],
},
]
const testTableDataWithNull = [
{
id: 1,
order: '1.00000000000000000000',
field_272: 'Tesla',
field_275: [],
},
{
id: 2,
order: '2.00000000000000000000',
field_272: 'Amazon',
field_275: [{ id: 144, value: 'A', color: 'blue' }],
},
{
id: 3,
order: '3.00000000000000000000',
field_272: '',
field_275: [
{ id: 144, value: 'A', color: 'blue' },
{ id: 145, value: 'B', color: 'orange' },
],
},
]
describe('MultipleSelectFieldType sorting', () => {
let testApp = null
let multipleSelectFieldType = null
let ASC = null
let DESC = null
let sortASC = firstBy()
let sortDESC = firstBy()
beforeAll(() => {
testApp = new TestApp()
multipleSelectFieldType = new MultipleSelectFieldType()
ASC = multipleSelectFieldType.getSort('field_275', 'ASC')
DESC = multipleSelectFieldType.getSort('field_275', 'DESC')
sortASC = sortASC.thenBy(ASC)
sortDESC = sortDESC.thenBy(DESC)
})
afterEach(() => {
testApp.afterEach()
})
test('Test ascending and descending order', () => {
testTableData.sort(sortASC)
let ids = testTableData.map((obj) => obj.id)
expect(ids).toEqual([2, 3, 4, 1, 6, 5])
testTableData.sort(sortDESC)
ids = testTableData.map((obj) => obj.id)
expect(ids).toEqual([5, 6, 1, 4, 3, 2])
})
test('Test ascending and descending order with null values', () => {
testTableDataWithNull.sort(sortASC)
let ids = testTableDataWithNull.map((obj) => obj.id)
expect(ids).toEqual([1, 2, 3])
testTableDataWithNull.sort(sortDESC)
ids = testTableDataWithNull.map((obj) => obj.id)
expect(ids).toEqual([3, 2, 1])
})
})

View file

@ -6,6 +6,8 @@ import {
DateEqualViewFilterType,
DateNotEqualViewFilterType,
DateEqualsTodayViewFilterType,
MultipleSelectHasFilterType,
MultipleSelectHasNotFilterType,
HasFileTypeViewFilterType,
} from '@baserow/modules/database/viewFilters'
@ -291,6 +293,60 @@ const dateToday = [
},
]
const multipleSelectValuesHas = [
{
rowValue: [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 154,
expected: true,
},
{
rowValue: [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 200,
expected: false,
},
{
rowValue: [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 'wrong_type',
expected: true,
},
]
const multipleSelectValuesHasNot = [
{
rowValue: [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 154,
expected: false,
},
{
rowValue: [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 200,
expected: true,
},
{
rowValue: [
{ id: 155, value: 'A', color: 'green' },
{ id: 154, value: 'B', color: 'green' },
],
filterValue: 'wrong_type',
expected: true,
},
]
describe('All Tests', () => {
let testApp = null
@ -402,6 +458,24 @@ describe('All Tests', () => {
expect(result).toBe(values.expected)
})
test.each(multipleSelectValuesHas)('MultipleSelect Has', (values) => {
const result = new MultipleSelectHasFilterType().matches(
values.rowValue,
values.filterValue,
{}
)
expect(result).toBe(values.expected)
})
test.each(multipleSelectValuesHasNot)('MultipleSelect Has Not', (values) => {
const result = new MultipleSelectHasNotFilterType().matches(
values.rowValue,
values.filterValue,
{}
)
expect(result).toBe(values.expected)
})
test('HasFileType contains image', () => {
expect(new HasFileTypeViewFilterType().matches([], '', {})).toBe(true)
expect(new HasFileTypeViewFilterType().matches([], 'image', {})).toBe(false)