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:
parent
bc1685e1c3
commit
8e273e6960
38 changed files with 2518 additions and 1337 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
premium
backend
src/baserow_premium
tests/baserow_premium/views
web-frontend/modules/baserow_premium
web-frontend
20
backend/src/baserow/contrib/database/api/rows/schemas.py
Normal file
20
backend/src/baserow/contrib/database/api/rows/schemas.py
Normal 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`."
|
||||
),
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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,)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
|
@ -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": []},
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -9,3 +9,4 @@
|
|||
@import 'views/kanban';
|
||||
@import 'views/decorators';
|
||||
@import 'impersonate_warning';
|
||||
@import 'views/conditional_color_value_provider_form';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()"
|
||||
>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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: {} }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -93,7 +93,7 @@ describe('ViewFilterForm component', () => {
|
|||
props = {
|
||||
primary: {},
|
||||
fields: [],
|
||||
view: { filters: {}, _: {} },
|
||||
view: { filters: [], _: {} },
|
||||
readOnly: false,
|
||||
},
|
||||
listeners = {}
|
||||
|
|
Loading…
Add table
Reference in a new issue