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:
parent
0330f2ff01
commit
7f53cfcda4
54 changed files with 4749 additions and 318 deletions
backend
src/baserow/contrib/database
apps.py
fields
exceptions.pyfield_converters.pyfield_helpers.pyfield_sortings.pyfield_types.pyfields.pymodels.pyregistries.py
migrations
views
tests
premium/backend/tests/baserow_premium/export
web-frontend
modules
core/assets/scss
database
test/unit/database
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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.
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
@ -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
|
||||
|
|
2
backend/tests/fixtures/__init__.py
vendored
2
backend/tests/fixtures/__init__.py
vendored
|
@ -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()
|
||||
|
|
18
backend/tests/fixtures/field.py
vendored
18
backend/tests/fixtures/field.py
vendored
|
@ -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
55
backend/tests/fixtures/row.py
vendored
Normal 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
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -2,3 +2,4 @@
|
|||
@import 'alert';
|
||||
@import 'button';
|
||||
@import 'filters';
|
||||
@import 'select_option';
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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 {
|
|
@ -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: {
|
|
@ -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() {
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 === '',
|
||||
}"
|
||||
>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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'
|
||||
|
|
40
web-frontend/modules/database/mixins/multipleSelectField.js
Normal file
40
web-frontend/modules/database/mixins/multipleSelectField.js
Normal 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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
89
web-frontend/modules/database/mixins/selectOptions.js
Normal file
89
web-frontend/modules/database/mixins/selectOptions.js
Normal 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
|
||||
},
|
||||
},
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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',
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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]
|
||||
|
|
121
web-frontend/test/unit/database/fieldTypesGetSort.spec.js
Normal file
121
web-frontend/test/unit/database/fieldTypesGetSort.spec.js
Normal 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])
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue