1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-07 22:35:36 +00:00

🌈 3️⃣ - Row coloring v3 - Add condition value provider

This commit is contained in:
Jrmi 2022-04-30 11:25:25 +00:00
parent bc1685e1c3
commit 8e273e6960
38 changed files with 2518 additions and 1337 deletions

View file

@ -0,0 +1,20 @@
from drf_spectacular.plumbing import build_object_type
row_names_response_schema = build_object_type(
{
"{table_id}*": {
"type": "object",
"description": "An object containing the row names of table `table_id`.",
"properties": {
"{row_id}*": {
"type": "string",
"description": (
"the name of the row with id `row_id` from table "
"with id `table_id`."
),
}
},
},
},
)

View file

@ -1,6 +1,6 @@
from django.urls import re_path
from .views import RowsView, RowView, RowMoveView, BatchRowsView
from .views import RowsView, RowView, RowMoveView, RowNamesView, BatchRowsView
app_name = "baserow.contrib.database.api.rows"
@ -22,4 +22,9 @@ urlpatterns = [
RowMoveView.as_view(),
name="move",
),
re_path(
r"names/$",
RowNamesView.as_view(),
name="names",
),
]

View file

@ -10,7 +10,10 @@ from rest_framework.views import APIView
from baserow.api.decorators import map_exceptions, validate_query_parameters
from baserow.api.errors import ERROR_USER_NOT_IN_GROUP
from baserow.api.exceptions import RequestBodyValidationException
from baserow.api.exceptions import (
RequestBodyValidationException,
QueryParameterValidationException,
)
from baserow.api.pagination import PageNumberPagination
from baserow.api.schemas import get_error_schema, CLIENT_SESSION_ID_SCHEMA_PARAMETER
from baserow.api.trash.errors import ERROR_CANNOT_DELETE_ALREADY_DELETED_ITEM
@ -56,6 +59,7 @@ from baserow.contrib.database.rows.exceptions import RowDoesNotExist, RowIdsNotU
from baserow.contrib.database.rows.handler import RowHandler
from baserow.contrib.database.table.exceptions import TableDoesNotExist
from baserow.contrib.database.table.handler import TableHandler
from baserow.contrib.database.table.models import Table
from baserow.contrib.database.tokens.exceptions import NoPermissionToTable
from baserow.contrib.database.tokens.handler import TokenHandler
from baserow.contrib.database.views.exceptions import (
@ -80,6 +84,7 @@ from baserow.contrib.database.fields.field_filters import (
FILTER_TYPE_AND,
FILTER_TYPE_OR,
)
from .schemas import row_names_response_schema
class RowsView(APIView):
@ -428,6 +433,109 @@ class RowsView(APIView):
return Response(serializer.data)
class RowNamesView(APIView):
authentication_classes = APIView.authentication_classes + [TokenAuthentication]
permission_classes = (IsAuthenticated,)
@extend_schema(
parameters=[
OpenApiParameter(
name="table__{id}",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=(
"A list of comma separated row ids to query from the table with "
"id {id}. For example, if you "
"want the name of row `42` and `43` from table `28` this parameter "
"will be `table__28=42,43`. You can specify multiple rows for "
"different tables but every tables must be in the same database. "
"You need at least read permission on all specified tables."
),
),
],
tags=["Database table rows"],
operation_id="list_database_table_row_names",
description=(
"Returns the names of the given row of the given tables. The name"
"of a row is the primary field value for this row. The result can be used"
"for example, when you want to display the name of a linked row from "
"another table."
),
responses={
200: row_names_response_schema,
400: get_error_schema(
[
"ERROR_USER_NOT_IN_GROUP",
]
),
401: get_error_schema(["ERROR_NO_PERMISSION_TO_TABLE"]),
404: get_error_schema(["ERROR_TABLE_DOES_NOT_EXIST"]),
},
)
@map_exceptions(
{
UserNotInGroup: ERROR_USER_NOT_IN_GROUP,
TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST,
NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE,
}
)
def get(self, request):
"""
Returns the names (i.e. primary field value) of specified rows of given tables.
Can be used when you want to display a row name referenced from another table.
"""
result = {}
database = None
table_handler = TableHandler()
token_handler = TokenHandler()
row_handler = RowHandler()
for name, value in request.GET.items():
if not name.startswith("table__"):
raise QueryParameterValidationException(
detail='Only table Id prefixed by "table__" are allowed as parameter.',
code="invalid_parameter",
)
try:
table_id = int(name[7:])
except ValueError:
raise QueryParameterValidationException(
detail=(f'Failed to parse table id in "{name}".'),
code="invalid_table_id",
)
try:
row_ids = [int(id) for id in value.split(",")]
except ValueError:
raise QueryParameterValidationException(
detail=(
f'Failed to parse row ids in "{value}" for '
f'"table__{table_id}" parameter.'
),
code="invalid_row_ids",
)
table_queryset = None
if database:
# Once we have the database, we want only tables from the same database
table_queryset = Table.objects.filter(database=database)
table = table_handler.get_table(table_id, base_queryset=table_queryset)
if not database:
# Check permission once
database = table.database
database.group.has_user(request.user, raise_error=True)
token_handler.check_table_permissions(request, "read", table, False)
result[table_id] = row_handler.get_row_names(table, row_ids)
return Response(result)
class RowView(APIView):
authentication_classes = APIView.authentication_classes + [TokenAuthentication]
permission_classes = (IsAuthenticated,)

View file

@ -306,6 +306,31 @@ class RowHandler:
return cast(GeneratedTableModelForUpdate, row)
def get_row_names(
self, table: "Table", row_ids: List[int], model: "GeneratedTableModel" = None
) -> Dict[str, int]:
"""
Returns the row names for all row ids specified in `row_ids` parameter from
the given table.
:param table: The table where the rows must be fetched from.
:param row_ids: The id of the rows that must be fetched.
:param model: If the correct model has already been generated it can be
provided so that it does not have to be generated for a second time.
:return: A dict of the requested rows names. The key are the row ids and the
values are the row names.
"""
if not model:
primary_field = table.field_set.get(primary=True)
model = table.get_model(
field_ids=[], fields=[primary_field], add_dependencies=False
)
queryset = model.objects.filter(pk__in=row_ids)
return {row.id: str(row) for row in queryset}
# noinspection PyMethodMayBeStatic
def has_row(self, user, table, row_id, raise_error=False, model=None):
"""
@ -313,7 +338,7 @@ class RowHandler:
This method is preferred over using get_row when you do not actually need to
access any values of the row as it will not construct a full model but instead
do a much more effecient query to check only if the row exists or not.
do a much more efficient query to check only if the row exists or not.
:param user: The user of whose behalf the row is being checked.
:type user: User

View file

@ -101,6 +101,17 @@ class AggregationTypeAlreadyRegistered(Exception):
"""Raised when trying to register an aggregation type that exists already."""
class DecoratorValueProviderTypeDoesNotExist(Exception):
"""Raised when trying to get a decorator value provider type that does not exist."""
class DecoratorValueProviderTypeAlreadyRegistered(Exception):
"""
Raised when trying to register a decorator value provider type that exists
already.
"""
class GridViewAggregationDoesNotSupportField(Exception):
"""
Raised when someone tries to use an aggregation type that doesn't support the

View file

@ -51,6 +51,7 @@ from .registries import (
view_type_registry,
view_filter_type_registry,
view_aggregation_type_registry,
decorator_value_provider_type_registry,
)
from .signals import (
view_created,
@ -359,6 +360,11 @@ class ViewHandler:
for view_type in view_type_registry.get_all():
view_type.after_field_type_change(field)
for (
decorator_value_provider_type
) in decorator_value_provider_type_registry.get_all():
decorator_value_provider_type.after_field_type_change(field)
def field_value_updated(self, updated_fields: Union[Iterable[Field], Field]):
"""
Called after a field value has been modified because of a row creation,

View file

@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Callable, Union, List, Iterable, Tuple
from typing import TYPE_CHECKING, Callable, Union, List, Iterable, Tuple, Dict, Any
from django.contrib.auth.models import User as DjangoUser
from django.db import models as django_models
@ -21,9 +21,6 @@ from baserow.core.registry import (
)
from baserow.contrib.database.fields import models as field_models
if TYPE_CHECKING:
from baserow.contrib.database.views.models import View
from .exceptions import (
ViewTypeAlreadyRegistered,
ViewTypeDoesNotExist,
@ -31,8 +28,13 @@ from .exceptions import (
ViewFilterTypeDoesNotExist,
AggregationTypeDoesNotExist,
AggregationTypeAlreadyRegistered,
DecoratorValueProviderTypeAlreadyRegistered,
DecoratorValueProviderTypeDoesNotExist,
)
if TYPE_CHECKING:
from baserow.contrib.database.views.models import View
class ViewType(
MapAPIExceptionsInstanceMixin,
@ -285,30 +287,25 @@ class ViewType(
id_mapping["database_view_sortings"][view_sort_id] = view_sort_object.id
if self.can_decorate:
def _update_field_id(node):
"""Update field ids deeply inside a deep object."""
if isinstance(node, list):
return [_update_field_id(subnode) for subnode in node]
if isinstance(node, dict):
res = {}
for key, value in node.items():
if key in ["field_id", "field"] and isinstance(value, int):
res[key] = id_mapping["database_fields"][value]
else:
res[key] = _update_field_id(value)
return res
return node
for view_decoration in decorations:
view_decoration_copy = view_decoration.copy()
view_decoration_id = view_decoration_copy.pop("id")
# Deeply update field ids to new one in value provider conf
view_decoration_copy["value_provider_conf"] = _update_field_id(
view_decoration_copy["value_provider_conf"]
)
if view_decoration["value_provider_type"]:
try:
value_provider_type = (
decorator_value_provider_type_registry.get(
view_decoration["value_provider_type"]
)
)
except DecoratorValueProviderTypeDoesNotExist:
pass
else:
view_decoration_copy = (
value_provider_type.set_import_serialized_value(
view_decoration_copy, id_mapping
)
)
view_decoration_object = ViewDecoration.objects.create(
view=view, **view_decoration_copy
@ -720,8 +717,57 @@ class ViewAggregationTypeRegistry(Registry):
already_registered_exception_class = AggregationTypeAlreadyRegistered
class DecoratorValueProviderType(Instance):
"""
By declaring a new `DecoratorValueProviderType` you can define hooks on events that
can affect the decoration value provider configuration.
"""
def set_import_serialized_value(
self, value: Dict[str, Any], id_mapping: Dict[str, Dict[int, Any]]
) -> Dict[str, Any]:
"""
This method is called before a decorator is imported. It can optionally be
modified. If the value_provider_conf for example points to a field or select
option id, it can be replaced with the correct value by doing a lookup in the
id_mapping.
:param value: The original exported value.
:param id_mapping: The map of exported ids to newly created ids that must be
updated when a new instance has been created.
:return: The new value that will be imported.
"""
def after_field_delete(self, deleted_field: field_models.Field):
"""
Triggered after a field has been deleted.
This hook gives the opportunity to react when a field is deleted.
:param deleted_field: the deleted field.
"""
def after_field_type_change(self, field: field_models.Field):
"""
This hook is called after the type of a field has changed and gives the
possibility to check compatibility or update configuration.
:param field: The concerned field.
"""
class DecoratorValueProviderTypeRegistry(Registry):
"""
This registry contains declared decorator value provider if needed.
"""
name = "decorator_value_provider_type"
does_not_exist_exception_class = DecoratorValueProviderTypeDoesNotExist
already_registered_exception_class = DecoratorValueProviderTypeAlreadyRegistered
# A default view type registry is created here, this is the one that is used
# throughout the whole Baserow application to add a new view type.
view_type_registry = ViewTypeRegistry()
view_filter_type_registry = ViewFilterTypeRegistry()
view_aggregation_type_registry = ViewAggregationTypeRegistry()
decorator_value_provider_type_registry = DecoratorValueProviderTypeRegistry()

View file

@ -32,3 +32,13 @@ def field_deleted(sender, field, **kwargs):
GalleryView.objects.filter(card_cover_image_field_id=field.id).update(
card_cover_image_field_id=None
)
from baserow.contrib.database.views.registries import (
decorator_value_provider_type_registry,
)
# Call value provider type hooks
for (
decorator_value_provider_type
) in decorator_value_provider_type_registry.get_all():
decorator_value_provider_type.after_field_delete(field)

View file

@ -1550,3 +1550,170 @@ def test_list_rows_returns_https_next_url(api_client, data_fixture, settings):
response_json["next"] == "https://testserver:80/api/database/rows/table/"
f"{table.id}/?page=2"
)
@pytest.mark.django_db
def test_list_row_names(api_client, data_fixture):
user, jwt_token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
table = data_fixture.create_database_table(user=user)
data_fixture.create_text_field(name="Name", table=table, primary=True)
# A table for another user
table_off = data_fixture.create_database_table()
# A table in the same database
table_2 = data_fixture.create_database_table(user=user, database=table.database)
data_fixture.create_text_field(name="Name", table=table_2, primary=True)
# A table in another database
table_3 = data_fixture.create_database_table(user=user)
data_fixture.create_text_field(name="Name", table=table_3, primary=True)
token = TokenHandler().create_token(user, table.database.group, "Good")
wrong_token = TokenHandler().create_token(user, table.database.group, "Wrong")
TokenHandler().update_token_permissions(user, wrong_token, True, False, True, True)
model = table.get_model(attribute_names=True)
model.objects.create(name="Alpha")
model.objects.create(name="Beta")
model.objects.create(name="Gamma")
model.objects.create(name="Omega")
model_2 = table_2.get_model(attribute_names=True)
model_2.objects.create(name="Monday")
model_2.objects.create(name="Tuesday")
model_3 = table_3.get_model(attribute_names=True)
model_3.objects.create(name="January")
url = reverse("api:database:rows:names")
response = api_client.get(
f"{url}?table__99999=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
response = api_client.get(
f"{url}?table__{table.id}=1,2,3&table__99999=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"
response = api_client.get(
f"{url}?table__{table_off.id}=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "ERROR_USER_NOT_IN_GROUP"
response = api_client.get(
f"{url}?table__{table.id}=1,2,3",
format="json",
HTTP_AUTHORIZATION="Token abc123",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "ERROR_TOKEN_DOES_NOT_EXIST"
response = api_client.get(
f"{url}?table__{table.id}=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"Token {wrong_token.key}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "ERROR_NO_PERMISSION_TO_TABLE"
user.is_active = False
user.save()
response = api_client.get(
f"{url}?table__{table.id}=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"Token {token.key}",
)
assert response.status_code == HTTP_401_UNAUTHORIZED
assert response.json()["error"] == "ERROR_USER_NOT_ACTIVE"
user.is_active = True
user.save()
response = api_client.get(
f"{url}?tabble__{table.id}=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response.json()["error"] == "ERROR_QUERY_PARAMETER_VALIDATION"
assert (
response.json()["detail"]
== 'Only table Id prefixed by "table__" are allowed as parameter.'
)
response = api_client.get(
f"{url}?table__12i=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response.json()["error"] == "ERROR_QUERY_PARAMETER_VALIDATION"
assert response.json()["detail"] == 'Failed to parse table id in "table__12i".'
response = api_client.get(
f"{url}?table__23=1p,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response.json()["error"] == "ERROR_QUERY_PARAMETER_VALIDATION"
assert (
response.json()["detail"]
== 'Failed to parse row ids in "1p,2,3" for "table__23" parameter.'
)
response = api_client.get(
f"{url}?table__{table.id}=1",
format="json",
HTTP_AUTHORIZATION=f"Token {token.key}",
)
assert response.status_code == HTTP_200_OK
# One query one table
response = api_client.get(
f"{url}?table__{table.id}=1,2,3",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json == {str(table.id): {"1": "Alpha", "2": "Beta", "3": "Gamma"}}
# 2 tables, one database
response = api_client.get(
f"{url}?table__{table.id}=1,2,3&table__{table_2.id}=1,2",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json == {
str(table.id): {"1": "Alpha", "2": "Beta", "3": "Gamma"},
str(table_2.id): {"1": "Monday", "2": "Tuesday"},
}
# Two tables, two databases
response = api_client.get(
f"{url}?table__{table.id}=1,2,3&table__{table_3.id}=1",
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_TABLE_DOES_NOT_EXIST"

View file

@ -36,14 +36,7 @@ def test_import_export_grid_view(data_fixture):
view_decoration = data_fixture.create_view_decoration(
view=grid_view,
value_provider_conf={
"field_id": field.id,
"other": [
{"field": field.id, "other": 1},
{"answer": 42, "field_id": field.id},
{"field": {"non_int": True}},
],
},
value_provider_conf={"config": 12},
)
id_mapping = {"database_fields": {field.id: imported_field.id}}
@ -80,14 +73,10 @@ def test_import_export_grid_view(data_fixture):
view_decoration.value_provider_type
== imported_view_decoration.value_provider_type
)
assert imported_view_decoration.value_provider_conf == {
"field_id": imported_field.id,
"other": [
{"field": imported_field.id, "other": 1},
{"answer": 42, "field_id": imported_field.id},
{"field": {"non_int": True}},
],
}
assert (
imported_view_decoration.value_provider_conf
== imported_view_decoration.value_provider_conf
)
assert view_decoration.order == imported_view_decoration.order
imported_field_options = imported_grid_view.get_field_options()

View file

@ -9,7 +9,10 @@ class BaserowPremiumConfig(AppConfig):
from baserow.api.user.registries import user_data_registry
from baserow.contrib.database.export.registries import table_exporter_registry
from baserow.contrib.database.rows.registries import row_metadata_registry
from baserow.contrib.database.views.registries import view_type_registry
from baserow.contrib.database.views.registries import (
view_type_registry,
decorator_value_provider_type_registry,
)
from baserow_premium.row_comments.row_metadata_types import (
RowCommentCountMetadataType,
@ -22,6 +25,10 @@ class BaserowPremiumConfig(AppConfig):
from .plugins import PremiumPlugin
from .export.exporter_types import JSONTableExporter, XMLTableExporter
from .views.view_types import KanbanViewType
from .views.decorator_value_provider_types import (
ConditionalColorValueProviderType,
SelectColorValueProviderType,
)
plugin_registry.register(PremiumPlugin())
@ -34,6 +41,11 @@ class BaserowPremiumConfig(AppConfig):
view_type_registry.register(KanbanViewType())
decorator_value_provider_type_registry.register(
ConditionalColorValueProviderType()
)
decorator_value_provider_type_registry.register(SelectColorValueProviderType())
# The signals must always be imported last because they use the registries
# which need to be filled first.
import baserow_premium.ws.signals # noqa: F403, F401

View file

@ -0,0 +1,159 @@
from baserow.contrib.database.fields.field_types import (
SingleSelectFieldType,
)
from baserow.contrib.database.views.models import ViewDecoration
from baserow.contrib.database.views.handler import ViewHandler
from baserow.contrib.database.views.registries import (
DecoratorValueProviderType,
view_filter_type_registry,
)
class SelectColorValueProviderType(DecoratorValueProviderType):
type = "single_select_color"
def set_import_serialized_value(self, value, id_mapping) -> str:
"""
Update the field id with the newly created one.
"""
old_field_id = value["value_provider_conf"].get("field_id", None)
if old_field_id:
value["value_provider_conf"]["field_id"] = id_mapping[
"database_fields"
].get(old_field_id, None)
return value
def after_field_delete(self, deleted_field):
"""
Remove the field from the value_provider_conf filters if necessary.
"""
view_handler = ViewHandler()
queryset = ViewDecoration.objects.filter(
view__table=deleted_field.table,
value_provider_type=SelectColorValueProviderType.type,
value_provider_conf__field_id=deleted_field.id,
)
for decoration in queryset.all():
new_conf = {**decoration.value_provider_conf}
new_conf["field_id"] = None
view_handler.update_decoration(decoration, value_provider_conf=new_conf)
def after_field_type_change(self, field):
"""
Unset the field if the type is not a select anymore.
"""
from baserow.contrib.database.fields.registries import field_type_registry
field_type = field_type_registry.get_by_model(field.specific_class)
view_handler = ViewHandler()
if field_type.type != SingleSelectFieldType.type:
queryset = ViewDecoration.objects.filter(
view__table=field.table,
value_provider_type=SelectColorValueProviderType.type,
value_provider_conf__field_id=field.id,
)
for decoration in queryset.all():
new_conf = {**decoration.value_provider_conf}
new_conf["field_id"] = None
view_handler.update_decoration(decoration, value_provider_conf=new_conf)
class ConditionalColorValueProviderType(DecoratorValueProviderType):
type = "conditional_color"
def set_import_serialized_value(self, value, id_mapping) -> str:
"""
Update the field ids of each filter with the newly created one.
"""
value_provider_conf = value["value_provider_conf"]
for color in value_provider_conf["colors"]:
for filter in color["filters"]:
new_field_id = id_mapping["database_fields"][filter["field"]]
filter["field"] = new_field_id
return value
def _map_filter_from_config(self, conf, fn):
"""
Map a function on each filters of the configuration. If the given function
returns None, the filter is removed from the list of filters.
"""
modified = False
for color in conf["colors"]:
new_filters = []
for filter in color["filters"]:
new_filter = fn(filter)
modified = modified or new_filter != filter
if new_filter is not None:
new_filters.append(new_filter)
modified = modified or len(new_filters) != len(color["filters"])
color["filters"] = new_filters
return conf, modified
def after_field_delete(self, deleted_field):
"""
Remove the field from the value_provider_conf filters if necessary.
"""
# We can't filter with the JSON field here as we have nested lists
queryset = ViewDecoration.objects.filter(
view__table=deleted_field.table,
value_provider_type=ConditionalColorValueProviderType.type,
)
view_handler = ViewHandler()
for decoration in queryset.all():
new_conf, modified = self._map_filter_from_config(
decoration.value_provider_conf,
lambda f: None if f["field"] == deleted_field.id else f,
)
if modified:
view_handler.update_decoration(decoration, value_provider_conf=new_conf)
def after_field_type_change(self, field):
"""
Remove filters type that are not compatible anymore from configuration
"""
queryset = ViewDecoration.objects.filter(
view__table=field.table,
value_provider_type=ConditionalColorValueProviderType.type,
)
view_handler = ViewHandler()
def compatible_filter_only(filter):
if filter["field"] != field.id:
return filter
filter_type = view_filter_type_registry.get(filter["type"])
if not filter_type.field_is_compatible(field):
return None
return filter
for decoration in queryset.all():
# Check which filters are not compatible anymore and remove those.
new_conf, modified = self._map_filter_from_config(
decoration.value_provider_conf,
compatible_filter_only,
)
if modified:
view_handler.update_decoration(decoration, value_provider_conf=new_conf)

View file

@ -1,7 +1,8 @@
import pytest
from baserow.contrib.database.views.models import View
from baserow.contrib.database.views.models import View, ViewDecoration
from baserow_premium.views.handler import get_rows_grouped_by_single_select_field
from baserow.contrib.database.fields.handler import FieldHandler
@pytest.mark.django_db
@ -232,3 +233,149 @@ def test_get_rows_grouped_by_single_select_field_with_empty_table(
assert len(rows) == 1
assert rows["null"]["count"] == 0
assert len(rows["null"]["results"]) == 0
@pytest.mark.django_db
def test_field_type_changed_w_decoration(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table)
option_field = data_fixture.create_single_select_field(
table=table, name="option_field", order=1
)
option_a = data_fixture.create_select_option(
field=option_field, value="A", color="blue"
)
grid_view = data_fixture.create_grid_view(table=table)
select_view_decoration = data_fixture.create_view_decoration(
view=grid_view,
value_provider_type="single_select_color",
value_provider_conf={"field_id": option_field.id},
order=1,
)
condition_view_decoration = data_fixture.create_view_decoration(
view=grid_view,
value_provider_type="conditional_color",
value_provider_conf={
"colors": [
{"filters": [{"field": text_field.id, "type": "equal"}]},
{"filters": [{"field": option_field.id, "type": "equal"}]},
{
"filters": [
{"field": option_field.id, "type": "single_select_equal"}
]
},
{"filters": []},
]
},
order=2,
)
field_handler = FieldHandler()
decorations = list(ViewDecoration.objects.all())
assert len(decorations) == 2
assert (
decorations[0].value_provider_conf == select_view_decoration.value_provider_conf
)
assert (
decorations[1].value_provider_conf
== condition_view_decoration.value_provider_conf
)
field_handler.update_field(
user=user, field=option_field, new_type_name="single_select"
)
decorations = list(ViewDecoration.objects.all())
assert (
decorations[0].value_provider_conf == select_view_decoration.value_provider_conf
)
assert (
decorations[1].value_provider_conf
== condition_view_decoration.value_provider_conf
)
field_handler.update_field(user=user, field=option_field, new_type_name="text")
decorations = list(ViewDecoration.objects.all())
assert decorations[0].value_provider_conf == {"field_id": None}
assert decorations[1].value_provider_conf == {
"colors": [
{"filters": [{"type": "equal", "field": text_field.id}]},
{"filters": [{"type": "equal", "field": option_field.id}]},
{"filters": []},
{"filters": []},
]
}
@pytest.mark.django_db
def test_field_deleted_w_decoration(data_fixture):
user = data_fixture.create_user()
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(table=table)
option_field = data_fixture.create_single_select_field(
table=table, name="option_field", order=1
)
option_a = data_fixture.create_select_option(
field=option_field, value="A", color="blue"
)
grid_view = data_fixture.create_grid_view(table=table)
select_view_decoration = data_fixture.create_view_decoration(
view=grid_view,
value_provider_type="single_select_color",
value_provider_conf={"field_id": option_field.id},
order=1,
)
condition_view_decoration = data_fixture.create_view_decoration(
view=grid_view,
value_provider_type="conditional_color",
value_provider_conf={
"colors": [
{"filters": [{"field": text_field.id, "type": "equal"}]},
{"filters": [{"field": option_field.id, "type": "equal"}]},
{
"filters": [
{"field": option_field.id, "type": "single_select_equal"}
]
},
{"filters": []},
]
},
order=2,
)
field_handler = FieldHandler()
field_handler.delete_field(user=user, field=option_field)
decorations = list(ViewDecoration.objects.all())
assert decorations[0].value_provider_conf == {"field_id": None}
assert decorations[1].value_provider_conf == {
"colors": [
{"filters": [{"type": "equal", "field": text_field.id}]},
{"filters": []},
{"filters": []},
{"filters": []},
]
}
field_handler.delete_field(user=user, field=text_field)
decorations = list(ViewDecoration.objects.all())
assert decorations[0].value_provider_conf == {"field_id": None}
assert decorations[1].value_provider_conf == {
"colors": [
{"filters": []},
{"filters": []},
{"filters": []},
{"filters": []},
]
}

View file

@ -130,6 +130,66 @@ def test_import_export_kanban_view(premium_data_fixture, tmpdir):
assert field_option.order == imported_field_option.order
@pytest.mark.django_db
def test_import_export_grid_view_w_decorator(data_fixture):
grid_view = data_fixture.create_grid_view(
name="Test", order=1, filter_type="AND", filters_disabled=False
)
field = data_fixture.create_text_field(table=grid_view.table)
imported_field = data_fixture.create_text_field(table=grid_view.table)
view_decoration = data_fixture.create_view_decoration(
view=grid_view,
value_provider_type="single_select_color",
value_provider_conf={"field_id": field.id},
order=1,
)
view_decoration_2 = data_fixture.create_view_decoration(
view=grid_view,
value_provider_type="conditional_color",
value_provider_conf={
"colors": [
{"filters": [{"field": field.id}]},
{"filters": [{"field": field.id}]},
]
},
order=2,
)
id_mapping = {"database_fields": {field.id: imported_field.id}}
grid_view_type = view_type_registry.get("grid")
serialized = grid_view_type.export_serialized(grid_view, None, None)
imported_grid_view = grid_view_type.import_serialized(
grid_view.table, serialized, id_mapping, None, None
)
imported_view_decorations = imported_grid_view.viewdecoration_set.all()
assert view_decoration.id != imported_view_decorations[0].id
assert view_decoration.type == imported_view_decorations[0].type
assert (
view_decoration.value_provider_type
== imported_view_decorations[0].value_provider_type
)
assert imported_view_decorations[0].value_provider_conf == {
"field_id": imported_field.id
}
assert view_decoration_2.id != imported_view_decorations[1].id
assert view_decoration_2.type == imported_view_decorations[1].type
assert (
view_decoration_2.value_provider_type
== imported_view_decorations[1].value_provider_type
)
assert imported_view_decorations[1].value_provider_conf == {
"colors": [
{"filters": [{"field": imported_field.id}]},
{"filters": [{"field": imported_field.id}]},
]
}
@pytest.mark.django_db
def test_newly_created_view(premium_data_fixture):
user = premium_data_fixture.create_user(has_active_premium_license=True)

View file

@ -9,3 +9,4 @@
@import 'views/kanban';
@import 'views/decorators';
@import 'impersonate_warning';
@import 'views/conditional_color_value_provider_form';

View file

@ -0,0 +1,65 @@
.conditional-color-value-provider-form__color {
background-color: $color-neutral-100;
padding: 12px 30px;
margin: 0 -20px;
margin-bottom: 12px;
& .filters__item {
margin-left: 1px;
}
}
.conditional-color-value-provider-form__color-header {
display: flex;
justify-content: left;
align-items: center;
}
.conditional-color-value-provider-form__color-handle {
width: 12px;
height: 24px;
background-image: radial-gradient($color-neutral-200 40%, transparent 40%);
background-size: 4px 4px;
background-repeat: repeat;
margin-right: 12px;
cursor: grab;
}
.conditional-color-value-provider-form__color-trash-link {
display: flex;
justify-content: center;
align-items: center;
color: $color-primary-900;
padding: 6px;
border-radius: 4px;
margin-left: 4px;
&:hover {
text-decoration: none;
background-color: $color-neutral-100;
}
}
.conditional-color-value-provider-form__color-color {
@extend %option-shadow;
padding: 6px 9px;
text-align: center;
color: $color-primary-900;
border-radius: 3px;
font-size: 14px;
}
.conditional-color-value-provider-form__color-filter--empty {
padding: 12px;
white-space: normal;
text-align: center;
}
.conditional-color-value-provider-form__color-filters {
margin-bottom: 12px;
}
.conditional-color-value-provider-form__color-filter-add {
color: $color-primary-900;
}

View file

@ -1,11 +1,87 @@
<template>
<div class="margin-bottom-2">Not available yet!</div>
<div>
<div>
<div
v-for="color in options.colors || []"
:key="color.uid"
v-sortable="{
id: color.uid,
update: orderColor,
handle: '[data-sortable-handle]',
marginTop: -5,
}"
class="conditional-color-value-provider-form__color"
>
<div class="conditional-color-value-provider-form__color-header">
<div
class="conditional-color-value-provider-form__color-handle"
data-sortable-handle
/>
<a
:ref="`colorSelect-${color.uid}`"
class="conditional-color-value-provider-form__color-color"
:class="`background-color--${color.color}`"
@click="openColor(color)"
>
<i class="fas fa-caret-down"></i>
</a>
<div :style="{ flex: 1 }" />
<a
v-if="options.colors.length > 1"
class="conditional-color-value-provider-form__color-trash-link"
@click="deleteColor(color)"
>
<i class="fa fa-trash" />
</a>
</div>
<div
v-if="color.filters.length === 0"
class="conditional-color-value-provider-form__color-filter--empty"
>
{{ $t('conditionalColorValueProviderForm.colorAlwaysApply') }}
</div>
<ViewFieldConditionsForm
v-show="color.filters.length !== 0"
class="conditional-color-value-provider-form__color-filters"
:filters="color.filters"
:disable-filter="false"
:filter-type="color.operator"
:primary="primary"
:fields="fields"
:view="view"
:read-only="readOnly"
@deleteFilter="deleteFilter(color, $event)"
@updateFilter="updateFilter(color, $event)"
@selectOperator="updateColor(color, { operator: $event })"
/>
<a
class="conditional-color-value-provider-form__color-filter-add"
@click.prevent="addFilter(color)"
>
<i class="fas fa-plus"></i>
{{ $t('conditionalColorValueProviderForm.addCondition') }}</a
>
<ColorSelectContext
:ref="`colorContext-${color.uid}`"
@selected="updateColor(color, { color: $event })"
></ColorSelectContext>
</div>
</div>
<a class="colors__add" @click.prevent="addColor()">
<i class="fas fa-plus"></i>
{{ $t('conditionalColorValueProviderForm.addColor') }}</a
>
</div>
</template>
<script>
import ViewFieldConditionsForm from '@baserow/modules/database/components/view/ViewFieldConditionsForm'
import ColorSelectContext from '@baserow/modules/core/components/ColorSelectContext'
import { ConditionalColorValueProviderType } from '@baserow_premium/decoratorValueProviders'
export default {
name: 'ConditionalColorValueProvider',
components: {},
components: { ViewFieldConditionsForm, ColorSelectContext },
props: {
options: {
type: Object,
@ -19,6 +95,10 @@ export default {
type: Object,
required: true,
},
primary: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
@ -28,6 +108,126 @@ export default {
required: true,
},
},
computed: {},
computed: {
allFields() {
return [this.primary, ...this.fields]
},
},
methods: {
orderColor(colorIds) {
const newColors = colorIds.map((colorId) =>
this.options.colors.find(({ uid }) => uid === colorId)
)
this.$emit('update', {
colors: newColors,
})
},
openColor(color) {
this.$refs[`colorContext-${color.uid}`][0].setActive(color.color)
this.$refs[`colorContext-${color.uid}`][0].toggle(
this.$refs[`colorSelect-${color.uid}`][0],
'bottom',
'left',
4
)
},
addColor() {
this.$emit('update', {
colors: [
...this.options.colors,
ConditionalColorValueProviderType.getDefaultColorConf(
this.$registry,
{
fields: this.allFields,
},
true
),
],
})
},
updateColor(color, values) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.uid === color.uid) {
return { ...colorConf, ...values }
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
deleteColor(color) {
const newColors = this.options.colors.filter(({ uid }) => {
return uid !== color.uid
})
this.$emit('update', {
colors: newColors,
})
},
addFilter(color) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.uid === color.uid) {
return {
...colorConf,
filters: [
...colorConf.filters,
ConditionalColorValueProviderType.getDefaultFilterConf(
this.$registry,
{
fields: this.allFields,
}
),
],
}
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
updateFilter(color, { filter, values }) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.uid === color.uid) {
const newFilters = colorConf.filters.map((filterConf) => {
if (filterConf.id === filter.id) {
return { ...filter, ...values }
}
return filterConf
})
return {
...colorConf,
filters: newFilters,
}
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
deleteFilter(color, filter) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.uid === color.uid) {
const newFilters = colorConf.filters.filter((filterConf) => {
return filterConf.id !== filter.id
})
return {
...colorConf,
filters: newFilters,
}
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
},
}
</script>

View file

@ -15,6 +15,7 @@
<script>
import ChooseSingleSelectField from '@baserow/modules/database/components/field/ChooseSingleSelectField.vue'
import { SingleSelectFieldType } from '@baserow/modules/database/fieldTypes'
export default {
name: 'SingleSelectColorValueProviderForm',
@ -32,6 +33,10 @@ export default {
type: Object,
required: true,
},
primary: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
@ -43,7 +48,9 @@ export default {
},
computed: {
selectFields() {
return this.fields.filter(({ type }) => type === 'single_select')
return [this.primary, ...this.fields].filter(
({ type }) => type === SingleSelectFieldType.getType()
)
},
value() {
return this.options && this.options.field_id

View file

@ -6,6 +6,9 @@ import {
BackgroundColorViewDecoratorType,
LeftBorderColorViewDecoratorType,
} from '@baserow_premium/viewDecorators'
import { uuid } from '@baserow/modules/core/utils/string'
import { randomColor } from '@baserow/modules/core/utils/colors'
import { matchSearchFilters } from '@baserow/modules/database/utils/view'
export class SingleSelectColorValueProviderType extends DecoratorValueProviderType {
static getType() {
@ -52,6 +55,41 @@ export class ConditionalColorValueProviderType extends DecoratorValueProviderTyp
return 'conditional_color'
}
static getDefaultFilterConf(registry, { fields }) {
const field = fields[0]
const filter = { field: field.id }
const viewFilterTypes = registry.getAll('viewFilter')
const compatibleType = Object.values(viewFilterTypes).find(
(viewFilterType) => {
return viewFilterType.fieldIsCompatible(field)
}
)
filter.type = compatibleType.type
const viewFilterType = registry.get('viewFilter', filter.type)
filter.value = viewFilterType.getDefaultValue()
filter.preload_values = {}
filter.id = uuid()
return filter
}
static getDefaultColorConf(registry, { fields }, noFilter = false) {
return {
color: randomColor(),
operator: 'AND',
filters: noFilter
? []
: [
ConditionalColorValueProviderType.getDefaultFilterConf(registry, {
fields,
}),
],
uid: uuid(),
}
}
getName() {
const { i18n } = this.app
return i18n.t('decoratorValueProviderType.conditionalColor')
@ -67,6 +105,12 @@ export class ConditionalColorValueProviderType extends DecoratorValueProviderTyp
}
getValue({ options, fields, row }) {
const { $registry } = this.app
for (const { color, filters, operator } of options.colors) {
if (matchSearchFilters($registry, operator, filters, fields, row)) {
return color
}
}
return ''
}
@ -77,4 +121,16 @@ export class ConditionalColorValueProviderType extends DecoratorValueProviderTyp
getFormComponent() {
return ConditionalColorValueProviderForm
}
getDefaultConfiguration({ fields }) {
const { $registry } = this.app
return {
default: null,
colors: [
ConditionalColorValueProviderType.getDefaultColorConf($registry, {
fields,
}),
],
}
}
}

View file

@ -237,5 +237,10 @@
},
"singleSelectColorValueProviderForm": {
"chooseAColor": "Which single select field should the row be colored by?"
},
"conditionalColorValueProviderForm": {
"addCondition": "add condition",
"colorAlwaysApply": "This color applies by default. You can add conditions by clicking on the \"Add condition\" button.",
"addColor": "add color"
}
}

View file

@ -1,5 +1,6 @@
.filters {
padding: 12px;
width: 550px;
.dropdown__selected {
@extend %ellipsis;
@ -23,34 +24,33 @@
.filters__item {
position: relative;
display: flex;
display: grid;
align-items: center;
// 142px = 20 + 82 + 10 * 4 (gaps)
grid-template-columns: 20px 82px calc(50% - 142px) 22% 28%;
padding: 6px 0;
border-radius: 3px;
margin-left: 5px;
column-gap: 10px;
&:not(:last-child) {
margin-bottom: 6px;
}
&.filters__item--loading {
padding-left: 32px;
&::before {
content: '';
margin-top: -7px;
@include loading(14px);
@include absolute(50%, auto, 0, 10px);
@include absolute(50%, auto, 0, -2px);
}
}
}
.filters__remove {
flex: 0 0 32px;
width: 32px;
color: $color-primary-900;
line-height: 30px;
text-align: center;
&:hover {
text-decoration: none;
@ -58,32 +58,16 @@
}
.filters__item--loading & {
display: none;
visibility: hidden;
}
}
.filters__operator {
flex: 0 0 72px;
width: 72px;
margin-right: 10px;
span {
padding-left: 12px;
}
}
.filters__field {
margin-right: 10px;
@include filter-dropdown-width(100px);
}
.filters__type {
margin-right: 10px;
@include filter-dropdown-width(100px);
}
.filters__value {
flex: 0 0;
}
@ -96,7 +80,6 @@
}
.filters__value-input {
width: 130px;
padding-top: 0;
padding-bottom: 0;
line-height: 30px;
@ -113,10 +96,6 @@
color: $color-neutral-400;
}
.filters__value-dropdown {
width: 130px;
}
.filters__value-rating {
border: solid 1px $color-neutral-400;
border-radius: 3px;
@ -128,13 +107,13 @@
display: block;
position: relative;
width: 130px;
color: $color-primary-900;
line-height: 30px;
height: 30px;
border: solid 1px $color-neutral-400;
border-radius: 3px;
padding: 0 10px;
background-color: $white;
&:hover {
text-decoration: none;
@ -150,6 +129,16 @@
border-color: $color-neutral-400;
}
}
&.filters__value-link-row--loading {
&::before {
content: '';
margin-top: -7px;
@include loading(14px);
@include absolute(50%, auto, 0, calc(50% - 7px));
}
}
}
.filters__value-link-row-choose {

View file

@ -7,8 +7,8 @@
>
<i class="dropdown__selected-icon fas" :class="'fa-' + icon" />
{{ name }}
<i class="dropdown__toggle-icon fas fa-caret-down"></i>
</a>
<i class="dropdown__toggle-icon fas fa-caret-down"></i>
<Context ref="pickerContext" class="picker__context">
<slot :hidePicker="hide" />
</Context>

View file

@ -16,18 +16,18 @@ import { findScrollableParent } from '@baserow/modules/core/utils/dom'
* <div
* v-for="item in items"
* :key="item.id"
* v-sortable="{ id: item.id, update: order }"
* v-sortable="{ id: item.id, update: onUpdate }"
* ></div>
*
* export default {
* data() {
* return {
* items: [{'id': 1, order: 1}, {'id': 2, order: 2}, {'id': 3, order: 3}]
* items: [{'id': 25, order: 1}, {'id': 27, order: 2}, {'id': 30, order: 3}]
* }
* },
* methods: {
* order(order) {
* console.log(order) // [1, 2, 3]
* onUpdate(itemIds) {
* console.log(itemIds) // [25, 27, 30]
* },
* },
* }
@ -76,6 +76,12 @@ export default {
parent = el.parentNode
scrollableParent = findScrollableParent(parent) || parent
// If the parent container is not positioned, add the position automatically.
if (getComputedStyle(parent).position === 'static') {
parent.style.position = 'relative'
}
indicator = document.createElement('div')
indicator.classList.add('sortable-position-indicator')
parent.insertBefore(indicator, parent.firstChild)

View file

@ -6,7 +6,7 @@
}}</label>
<div class="control__elements">
<a
:ref="'color-select'"
ref="color-select"
:class="'rating-field__color' + ' background-color--' + values.color"
@click="openColor()"
>

View file

@ -59,8 +59,9 @@
v-if="dec.valueProviderType"
:view="view"
:table="table"
:fields="allFields"
:read-only="readOnly || dec.decoration._.loading"
:primary="primary"
:fields="fields"
:read-only="readOnly"
:options="dec.decoration.value_provider_conf"
@update="updateDecorationOptions(dec.decoration, $event)"
/>
@ -192,7 +193,7 @@ export default {
value_provider_type: valueProviderType.getType(),
value_provider_conf: valueProviderType.getDefaultConfiguration({
view: this.view,
fields: this.fields,
fields: this.allFields,
}),
},
decoration,

View file

@ -0,0 +1,255 @@
<template>
<div>
<!--
Here we use the index as key to avoid loosing focus when filter id change.
-->
<div
v-for="(filter, index) in filters"
:key="index"
class="filters__item"
:class="{
'filters__item--loading': filter._ && filter._.loading,
}"
>
<a
v-if="!disableFilter"
class="filters__remove"
@click="deleteFilter(filter)"
>
<i class="fas fa-times"></i>
</a>
<div class="filters__operator">
<span v-if="index === 0">{{ $t('viewFilterContext.where') }}</span>
<Dropdown
v-if="index === 1 && !disableFilter"
:value="filterType"
:show-search="false"
class="dropdown--floating dropdown--tiny"
@input="selectBooleanOperator($event)"
>
<DropdownItem
:name="$t('viewFilterContext.and')"
value="AND"
></DropdownItem>
<DropdownItem
:name="$t('viewFilterContext.or')"
value="OR"
></DropdownItem>
</Dropdown>
<span v-if="index > 1 || (index > 0 && disableFilter)">
{{
filterType === 'AND'
? $t('viewFilterContext.and')
: $t('viewFilterContext.or')
}}
</span>
</div>
<div class="filters__field">
<Dropdown
:value="filter.field"
:disabled="disableFilter"
class="dropdown--floating dropdown--tiny"
@input="updateFilter(filter, { field: $event })"
>
<DropdownItem
:key="'primary-' + primary.id"
:name="primary.name"
:value="primary.id"
:disabled="hasNoCompatibleFilterTypes(primary, filterTypes)"
></DropdownItem>
<DropdownItem
v-for="field in fields"
:key="'field-' + field.id"
:name="field.name"
:value="field.id"
:disabled="hasNoCompatibleFilterTypes(field, filterTypes)"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__type">
<Dropdown
:disabled="disableFilter"
:value="filter.type"
class="dropdown--floating dropdown--tiny"
@input="updateFilter(filter, { type: $event })"
>
<DropdownItem
v-for="fType in allowedFilters(
filterTypes,
primary,
fields,
filter.field
)"
:key="fType.type"
:name="fType.getName()"
:value="fType.type"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__value">
<component
:is="getInputComponent(filter.type, filter.field)"
:ref="`filter-value-${index}`"
:filter="filter"
:view="view"
:fields="fields"
:primary="primary"
:disabled="disableFilter"
:read-only="readOnly"
@input="updateFilter(filter, { value: $event })"
/>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'ViewFieldConditionsForm',
props: {
filters: {
type: Array,
required: true,
},
disableFilter: {
type: Boolean,
required: true,
},
filterType: {
type: String,
required: true,
},
primary: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
view: {
type: Object,
required: true,
},
readOnly: {
type: Boolean,
required: true,
},
},
computed: {
filterTypes() {
return this.$registry.getAll('viewFilter')
},
localFilters() {
// Copy the filters
return [...this.filters]
},
},
watch: {
/**
* When a filter has been created or removed we want to focus on last value. By
* watching localFilters instead of filters, the new and old values are differents.
*/
localFilters(value, old) {
if (value.length !== old.length) {
this.$nextTick(() => {
this.focusValue(value.length - 1)
})
}
},
},
methods: {
focusValue(position) {
const ref = `filter-value-${position}`
if (
position >= 0 &&
Object.prototype.hasOwnProperty.call(this.$refs, ref) &&
this.$refs[ref][0] &&
Object.prototype.hasOwnProperty.call(this.$refs[ref][0], 'focus')
) {
this.$refs[ref][0].focus()
}
},
/**
* Indicates if the field has any compatible filter types.
*/
hasNoCompatibleFilterTypes(field, filterTypes) {
for (const type in filterTypes) {
if (filterTypes[type].fieldIsCompatible(field)) {
return false
}
}
return true
},
/**
* Returns a list of filter types that are allowed for the given fieldId.
*/
allowedFilters(filterTypes, primary, fields, fieldId) {
const field =
primary.id === fieldId ? primary : fields.find((f) => f.id === fieldId)
return Object.values(filterTypes).filter((filterType) => {
return field !== undefined && filterType.fieldIsCompatible(field)
})
},
deleteFilter(filter) {
this.$emit('deleteFilter', filter)
},
/**
* Updates a filter with the given values. Some data manipulation will also be done
* because some filter types are not compatible with certain field types.
*/
updateFilter(filter, values) {
const field = Object.prototype.hasOwnProperty.call(values, 'field')
? values.field
: filter.field
const type = Object.prototype.hasOwnProperty.call(values, 'type')
? values.type
: filter.type
const value = Object.prototype.hasOwnProperty.call(values, 'value')
? values.value
: filter.value
// If the field has changed we need to check if the filter type is compatible
// and if not we are going to choose the first compatible type.
if (Object.prototype.hasOwnProperty.call(values, 'field')) {
const allowedFilterTypes = this.allowedFilters(
this.filterTypes,
this.primary,
this.fields,
field
).map((filter) => filter.type)
if (!allowedFilterTypes.includes(type)) {
values.type = allowedFilterTypes[0]
}
}
// If the type or value has changed it could be that the value needs to be
// formatted or prepared.
if (
Object.prototype.hasOwnProperty.call(values, 'type') ||
Object.prototype.hasOwnProperty.call(values, 'value')
) {
const filterType = this.$registry.get('viewFilter', type)
values.value = filterType.prepareValue(value)
}
this.$emit('updateFilter', { filter, values })
},
selectBooleanOperator(value) {
this.$emit('selectOperator', value)
},
/**
* Returns the input component related to the filter type. This component is
* responsible for updating the filter value.
*/
getInputComponent(type, fieldId) {
const field =
this.primary.id === fieldId
? this.primary
: this.fields.find(({ id }) => id === fieldId)
return this.$registry.get('viewFilter', type).getInputComponent(field)
},
},
}
</script>

View file

@ -1,6 +1,6 @@
<template>
<div>
<div v-show="view.filters.length === 0">
<div v-if="view.filters.length === 0">
<div class="filters__none">
<div class="filters__none-title">
{{ $t('viewFilterContext.noFilterTitle') }}
@ -10,110 +10,18 @@
</div>
</div>
</div>
<div
v-for="(filter, index) in view.filters"
:key="filter.id"
class="filters__item"
:class="{
'filters__item--loading': filter._.loading,
}"
>
<a
v-if="!disableFilter"
class="filters__remove"
@click.stop.prevent="deleteFilter(filter)"
>
<i class="fas fa-times"></i>
</a>
<div class="filters__operator">
<span v-if="index === 0">{{ $t('viewFilterContext.where') }}</span>
<Dropdown
v-if="index === 1 && !disableFilter"
:value="view.filter_type"
:show-search="false"
class="dropdown--floating dropdown--tiny"
@input="updateView(view, { filter_type: $event })"
>
<DropdownItem
:name="$t('viewFilterContext.and')"
value="AND"
></DropdownItem>
<DropdownItem
:name="$t('viewFilterContext.or')"
value="OR"
></DropdownItem>
</Dropdown>
<span
v-if="
(index > 1 || (index > 0 && disableFilter)) &&
view.filter_type === 'AND'
"
>{{ $t('viewFilterContext.and') }}</span
>
<span
v-if="
(index > 1 || (index > 0 && disableFilter)) &&
view.filter_type === 'OR'
"
>{{ $t('viewFilterContext.or') }}</span
>
</div>
<div class="filters__field">
<Dropdown
:value="filter.field"
:disabled="disableFilter"
class="dropdown--floating dropdown--tiny"
@input="updateFilter(filter, { field: $event })"
>
<DropdownItem
:key="'filter-field-' + filter.id + '-' + primary.id"
:name="primary.name"
:value="primary.id"
:disabled="hasNoCompatibleFilterTypes(primary, filterTypes)"
></DropdownItem>
<DropdownItem
v-for="field in fields"
:key="'filter-field-' + filter.id + '-' + field.id"
:name="field.name"
:value="field.id"
:disabled="hasNoCompatibleFilterTypes(field, filterTypes)"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__type">
<Dropdown
:disabled="disableFilter"
:value="filter.type"
class="dropdown--floating dropdown--tiny"
@input="updateFilter(filter, { type: $event })"
>
<DropdownItem
v-for="filterType in allowedFilters(
filterTypes,
primary,
fields,
filter.field
)"
:key="filterType.type"
:name="filterType.getName()"
:value="filterType.type"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__value">
<component
:is="getInputComponent(filter.type, filter.field)"
:ref="'filter-' + filter.id + '-value'"
:filter="filter"
:view="view"
:fields="fields"
:primary="primary"
:disabled="disableFilter"
:read-only="readOnly"
@input="updateFilter(filter, { value: $event })"
/>
</div>
</div>
<ViewFieldConditionsForm
:filters="view.filters"
:disable-filter="disableFilter"
:filter-type="view.filter_type"
:primary="primary"
:fields="fields"
:view="view"
:read-only="readOnly"
@deleteFilter="deleteFilter($event)"
@updateFilter="updateFilter($event)"
@selectOperator="updateView(view, { filter_type: $event })"
/>
<div v-if="!disableFilter" class="filters_footer">
<a class="filters__add" @click.prevent="addFilter()">
<i class="fas fa-plus"></i>
@ -132,9 +40,13 @@
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewFieldConditionsForm from '@baserow/modules/database/components/view/ViewFieldConditionsForm'
export default {
name: 'ViewFilterForm',
components: {
ViewFieldConditionsForm,
},
props: {
primary: {
type: Object,
@ -162,54 +74,10 @@ export default {
return this.$registry.getAll('viewFilter')
},
},
beforeMount() {
this.$bus.$on('view-filter-created', this.filterCreated)
},
beforeDestroy() {
this.$bus.$off('view-filter-created', this.filterCreated)
},
methods: {
/**
* When the filter has been created we want to focus on the value.
*/
filterCreated({ filter }) {
this.$nextTick(() => {
this.focusValue(filter)
})
},
focusValue(filter) {
const ref = 'filter-' + filter.id + '-value'
if (
Object.prototype.hasOwnProperty.call(this.$refs, ref) &&
Object.prototype.hasOwnProperty.call(this.$refs[ref][0], 'focus')
) {
this.$refs[ref][0].focus()
}
},
/**
* Indicates if the field has any compatible filter types.
*/
hasNoCompatibleFilterTypes(field, filterTypes) {
for (const type in filterTypes) {
if (filterTypes[type].fieldIsCompatible(field)) {
return false
}
}
return true
},
/**
* Returns a list of filter types that are allowed for the given fieldId.
*/
allowedFilters(filterTypes, primary, fields, fieldId) {
const field =
primary.id === fieldId ? primary : fields.find((f) => f.id === fieldId)
return Object.values(filterTypes).filter((filterType) => {
return field !== undefined && filterType.fieldIsCompatible(field)
})
},
async addFilter() {
async addFilter(values) {
try {
const { filter } = await this.$store.dispatch('view/createFilter', {
await this.$store.dispatch('view/createFilter', {
view: this.view,
field: this.primary,
values: {
@ -219,11 +87,6 @@ export default {
readOnly: this.readOnly,
})
this.$emit('changed')
// Wait for the filter to be rendered and then focus on the value input.
this.$nextTick(() => {
this.focusValue(filter)
})
} catch (error) {
notifyIf(error, 'view')
}
@ -244,41 +107,7 @@ export default {
* Updates a filter with the given values. Some data manipulation will also be done
* because some filter types are not compatible with certain field types.
*/
async updateFilter(filter, values) {
const field = Object.prototype.hasOwnProperty.call(values, 'field')
? values.field
: filter.field
const type = Object.prototype.hasOwnProperty.call(values, 'type')
? values.type
: filter.type
const value = Object.prototype.hasOwnProperty.call(values, 'value')
? values.value
: filter.value
// If the field has changed we need to check if the filter type is compatible
// and if not we are going to choose the first compatible type.
if (Object.prototype.hasOwnProperty.call(values, 'field')) {
const allowedFilterTypes = this.allowedFilters(
this.filterTypes,
this.primary,
this.fields,
field
).map((filter) => filter.type)
if (!allowedFilterTypes.includes(type)) {
values.type = allowedFilterTypes[0]
}
}
// If the type or value has changed it could be that the value needs to be
// formatted or prepared.
if (
Object.prototype.hasOwnProperty.call(values, 'type') ||
Object.prototype.hasOwnProperty.call(values, 'value')
) {
const filterType = this.$registry.get('viewFilter', type)
values.value = filterType.prepareValue(value)
}
async updateFilter({ filter, values }) {
try {
await this.$store.dispatch('view/updateFilter', {
filter,
@ -310,14 +139,6 @@ export default {
this.$store.dispatch('view/setItemLoading', { view, value: false })
},
/**
* Returns the input component related to the filter type. This component is
* responsible for updating the filter value.
*/
getInputComponent(type, fieldId) {
const field = this.fields.find(({ id }) => id === fieldId)
return this.$registry.get('viewFilter', type).getInputComponent(field)
},
},
}
</script>

View file

@ -11,15 +11,22 @@
<a
v-else
class="filters__value-link-row"
:class="{ 'filters__value-link-row--disabled': disabled }"
:class="{
'filters__value-link-row--disabled': disabled,
'filters__value-link-row--loading': loading,
}"
@click.prevent="!disabled && $refs.selectModal.show()"
>
<template v-if="valid">
{{ name || $t('viewFilterTypeLinkRow.unnamed', { value: filter.value }) }}
<template v-if="!loading">
<template v-if="valid">
{{
name || $t('viewFilterTypeLinkRow.unnamed', { value: filter.value })
}}
</template>
<div v-else class="filters__value-link-row-choose">
{{ $t('viewFilterTypeLinkRow.choose') }}
</div>
</template>
<div v-else class="filters__value-link-row-choose">
{{ $t('viewFilterTypeLinkRow.choose') }}
</div>
<SelectRowModal
v-if="!disabled"
ref="selectModal"
@ -35,6 +42,7 @@ import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdow
import SelectRowModal from '@baserow/modules/database/components/row/SelectRowModal'
import viewFilter from '@baserow/modules/database/mixins/viewFilter'
import ViewService from '@baserow/modules/database/services/view'
import RowService from '@baserow/modules/database/services/row'
export default {
name: 'ViewFilterTypeLinkRow',
@ -43,41 +51,55 @@ export default {
data() {
return {
name: '',
rowInfo: null,
loading: false,
}
},
computed: {
valid() {
return this.isValidValue(this.filter.value)
return isNumeric(this.filter.value)
},
},
watch: {
'filter.preload_values'(value) {
this.setNameFromPreloadValues(value)
'filter.value'() {
this.setName()
},
},
mounted() {
this.setNameFromPreloadValues(this.filter.preload_values)
this.setName()
},
methods: {
setNameFromRow(row, primary) {
this.name = this.$registry
.get('field', primary.type)
.toHumanReadableString(primary, row[`field_${primary.id}`])
},
setNameFromPreloadValues(values) {
if (Object.prototype.hasOwnProperty.call(values, 'display_name')) {
this.name = values.display_name
}
},
isValidValue() {
if (!isNumeric(this.filter.value)) {
return false
}
async setName() {
const { value, preload_values: { display_name: displayName } = {} } =
this.filter
return true
if (!value) {
this.name = ''
} else if (displayName) {
// set the name from preload_values
this.name = displayName
} else if (this.rowInfo) {
// Set the name from previous row info
const { row, primary } = this.rowInfo
this.name = this.$registry
.get('field', primary.type)
.toHumanReadableString(primary, row[`field_${primary.id}`])
this.rowInfo = null
} else {
// Get the name from server
this.loading = true
try {
this.name = await RowService(this.$client).getName(
this.field.link_row_table,
value
)
} finally {
this.loading = false
}
}
},
setValue({ row, primary }) {
this.setNameFromRow(row, primary)
this.rowInfo = { row, primary }
this.$emit('input', row.id.toString())
},
fetchPage(page, search) {

View file

@ -16,6 +16,7 @@
ref="left"
class="grid-view__left"
:fields="leftFields"
:all-table-fields="allTableFields"
:table="table"
:view="view"
:include-field-width-handles="false"
@ -64,6 +65,7 @@
ref="right"
class="grid-view__right"
:fields="visibleFields"
:all-table-fields="allTableFields"
:table="table"
:view="view"
:include-add-field="true"
@ -276,6 +278,9 @@ export default {
leftWidth() {
return this.leftFieldsWidth + this.gridViewRowDetailsWidth
},
allTableFields() {
return [this.primary, ...this.fields]
},
},
watch: {
fieldOptions: {

View file

@ -70,7 +70,7 @@
:is="dec.component"
v-for="dec in firstCellDecorations"
:key="dec.decoration.id"
:value="dec.propsFn(row).value"
v-bind="dec.propsFn(row)"
/>
</div>
</div>

View file

@ -41,6 +41,10 @@ export default {
type: Array,
required: true,
},
allTableFields: {
type: Array,
required: true,
},
leftOffset: {
type: Number,
required: false,
@ -86,11 +90,12 @@ export default {
'decoratorValueProvider',
decoration.value_provider_type
)
deco.propsFn = (row) => {
return {
value: deco.valueProviderType.getValue({
row,
fields: this.allFields,
fields: this.allTableFields,
options: decoration.value_provider_conf,
}),
}

View file

@ -52,6 +52,7 @@
:view="view"
:fields="fieldsToRender"
:all-fields="fields"
:all-table-fields="allTableFields"
:left-offset="fieldsLeftOffset"
:include-row-details="includeRowDetails"
:read-only="readOnly"
@ -126,6 +127,10 @@ export default {
type: Array,
required: true,
},
allTableFields: {
type: Array,
required: true,
},
table: {
type: Object,
required: true,

View file

@ -1,4 +1,28 @@
let pendingGetQueries = {}
let delay = null
const GRACE_DELAY = 50 // ms before querying the backend with a get query
export default (client) => {
const getNameCallback = async () => {
const config = {}
config.params = Object.fromEntries(
Object.entries(pendingGetQueries).map(([tableId, rows]) => {
const rowIds = Object.keys(rows)
return [`table__${tableId}`, rowIds.join(',')]
})
)
const { data } = await client.get(`/database/rows/names/`, config)
Object.entries(data).forEach(([tableId, rows]) => {
Object.entries(rows).forEach(([rowId, rowName]) => {
pendingGetQueries[tableId][rowId].forEach((resolve) => resolve(rowName))
})
})
pendingGetQueries = {}
delay = null
}
return {
get(tableId, rowId) {
return client.get(`/database/rows/table/${tableId}/${rowId}/`)
@ -17,6 +41,25 @@ export default (client) => {
return client.get(`/database/rows/table/${tableId}/`, config)
},
/**
* Returns the name of specified table row. Batch consecutive queries into one
* during the defined GRACE_TIME.
*/
getName(tableId, rowId) {
return new Promise((resolve) => {
clearTimeout(delay)
if (!pendingGetQueries[tableId]) {
pendingGetQueries[tableId] = {}
}
if (!pendingGetQueries[tableId][rowId]) {
pendingGetQueries[tableId][rowId] = []
}
pendingGetQueries[tableId][rowId].push(resolve)
delay = setTimeout(getNameCallback, GRACE_DELAY)
})
},
create(tableId, values, beforeId = null) {
const config = { params: {} }

View file

@ -479,6 +479,10 @@ export const actions = {
commit('ADD_FILTER', { view, filter })
if (emitEvent) {
this.$bus.$emit('view-filter-created', { view, filter })
}
try {
if (!readOnly) {
const { data } = await FilterService(this.$client).create(
@ -487,10 +491,6 @@ export const actions = {
)
commit('FINALIZE_FILTER', { view, oldId: filter.id, id: data.id })
}
if (emitEvent) {
this.$bus.$emit('view-filter-created', { view, filter })
}
} catch (error) {
commit('DELETE_FILTER', { view, id: filter.id })
throw error

View file

@ -14,6 +14,7 @@
"dev": "nuxt --hostname 0.0.0.0",
"start": "nuxt start --hostname 0.0.0.0",
"eslint": "eslint -c .eslintrc.js --ext .js,.vue . ../premium/web-frontend",
"lint": "yarn eslint && yarn stylelint",
"stylelint": "stylelint **/*.scss ../premium/web-frontend/**/*.scss --syntax scss",
"jest": "jest --verbose false",
"test": "yarn jest"
@ -79,4 +80,4 @@
"stylelint-webpack-plugin": "^3.0.1",
"vue-jest": "^3.0.3"
}
}
}

View file

@ -207,13 +207,12 @@ exports[`GridViewRows component with decoration Should show can add decorator to
/>
Fake value provider
<i
class="dropdown__toggle-icon fas fa-caret-down"
/>
</a>
<i
class="dropdown__toggle-icon fas fa-caret-down"
/>
</div>
</div>
@ -510,13 +509,12 @@ exports[`GridViewRows component with decoration Should show unavailable decorato
/>
Fake value provider
<i
class="dropdown__toggle-icon fas fa-caret-down"
/>
</a>
<i
class="dropdown__toggle-icon fas fa-caret-down"
/>
</div>
</div>
@ -762,13 +760,12 @@ exports[`GridViewRows component with decoration View with decoration configured
/>
Fake value provider
<i
class="dropdown__toggle-icon fas fa-caret-down"
/>
</a>
<i
class="dropdown__toggle-icon fas fa-caret-down"
/>
</div>
</div>

View file

@ -93,7 +93,7 @@ describe('ViewFilterForm component', () => {
props = {
primary: {},
fields: [],
view: { filters: {}, _: {} },
view: { filters: [], _: {} },
readOnly: false,
},
listeners = {}