From 8e273e696059c18e8fa17c11ad1237243f718c14 Mon Sep 17 00:00:00 2001 From: Jrmi <jrmi+gitlab@jeremiez.net> Date: Sat, 30 Apr 2022 11:25:25 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=88=203=EF=B8=8F=E2=83=A3=20-=20Row=20?= =?UTF-8?q?coloring=20v3=20-=20Add=20condition=20value=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contrib/database/api/rows/schemas.py | 20 + .../baserow/contrib/database/api/rows/urls.py | 7 +- .../contrib/database/api/rows/views.py | 110 +- .../baserow/contrib/database/rows/handler.py | 27 +- .../contrib/database/views/exceptions.py | 11 + .../baserow/contrib/database/views/handler.py | 6 + .../contrib/database/views/registries.py | 94 +- .../baserow/contrib/database/views/signals.py | 10 + .../database/api/rows/test_row_views.py | 167 ++ .../contrib/database/view/test_view_types.py | 21 +- premium/backend/src/baserow_premium/apps.py | 14 +- .../views/decorator_value_provider_types.py | 159 ++ .../views/test_premium_view_handler.py | 149 +- .../views/test_premium_view_types.py | 60 + .../assets/scss/components/all.scss | 1 + ...conditional_color_value_provider_form.scss | 65 + .../ConditionalColorValueProviderForm.vue | 206 +- .../SingleSelectColorValueProviderForm.vue | 9 +- .../decoratorValueProviders.js | 56 + .../modules/baserow_premium/locales/en.json | 5 + .../core/assets/scss/components/filters.scss | 49 +- .../modules/core/components/Picker.vue | 2 +- .../modules/core/directives/sortable.js | 14 +- .../components/field/FieldRatingSubForm.vue | 2 +- .../components/view/ViewDecoratorContext.vue | 7 +- .../view/ViewFieldConditionsForm.vue | 255 +++ .../components/view/ViewFilterForm.vue | 219 +- .../components/view/ViewFilterTypeLinkRow.vue | 74 +- .../components/view/grid/GridView.vue | 5 + .../components/view/grid/GridViewRow.vue | 2 +- .../components/view/grid/GridViewRows.vue | 7 +- .../components/view/grid/GridViewSection.vue | 5 + web-frontend/modules/database/services/row.js | 43 + web-frontend/modules/database/store/view.js | 8 +- web-frontend/package.json | 3 +- .../viewDecoratorContext.spec.js.snap | 27 +- .../__snapshots__/viewFilterForm.spec.js.snap | 1934 ++++++++--------- .../components/view/viewFilterForm.spec.js | 2 +- 38 files changed, 2518 insertions(+), 1337 deletions(-) create mode 100644 backend/src/baserow/contrib/database/api/rows/schemas.py create mode 100644 premium/backend/src/baserow_premium/views/decorator_value_provider_types.py create mode 100644 premium/web-frontend/modules/baserow_premium/assets/scss/components/views/conditional_color_value_provider_form.scss create mode 100644 web-frontend/modules/database/components/view/ViewFieldConditionsForm.vue diff --git a/backend/src/baserow/contrib/database/api/rows/schemas.py b/backend/src/baserow/contrib/database/api/rows/schemas.py new file mode 100644 index 000000000..07c08b2a9 --- /dev/null +++ b/backend/src/baserow/contrib/database/api/rows/schemas.py @@ -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`." + ), + } + }, + }, + }, +) diff --git a/backend/src/baserow/contrib/database/api/rows/urls.py b/backend/src/baserow/contrib/database/api/rows/urls.py index ee3720de8..47ab97130 100644 --- a/backend/src/baserow/contrib/database/api/rows/urls.py +++ b/backend/src/baserow/contrib/database/api/rows/urls.py @@ -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", + ), ] diff --git a/backend/src/baserow/contrib/database/api/rows/views.py b/backend/src/baserow/contrib/database/api/rows/views.py index ab2c3c836..8a6e46d09 100644 --- a/backend/src/baserow/contrib/database/api/rows/views.py +++ b/backend/src/baserow/contrib/database/api/rows/views.py @@ -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,) diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py index ae1b24a60..9ac3099d8 100644 --- a/backend/src/baserow/contrib/database/rows/handler.py +++ b/backend/src/baserow/contrib/database/rows/handler.py @@ -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 diff --git a/backend/src/baserow/contrib/database/views/exceptions.py b/backend/src/baserow/contrib/database/views/exceptions.py index 269d54cf1..beb3954b6 100644 --- a/backend/src/baserow/contrib/database/views/exceptions.py +++ b/backend/src/baserow/contrib/database/views/exceptions.py @@ -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 diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 212eb4872..7c1775b5a 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -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, diff --git a/backend/src/baserow/contrib/database/views/registries.py b/backend/src/baserow/contrib/database/views/registries.py index 4979877e1..b77f147f5 100644 --- a/backend/src/baserow/contrib/database/views/registries.py +++ b/backend/src/baserow/contrib/database/views/registries.py @@ -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() diff --git a/backend/src/baserow/contrib/database/views/signals.py b/backend/src/baserow/contrib/database/views/signals.py index 8a8ebd0a0..12ed3af89 100644 --- a/backend/src/baserow/contrib/database/views/signals.py +++ b/backend/src/baserow/contrib/database/views/signals.py @@ -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) diff --git a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py index 954cc809a..4f537b40d 100644 --- a/backend/tests/baserow/contrib/database/api/rows/test_row_views.py +++ b/backend/tests/baserow/contrib/database/api/rows/test_row_views.py @@ -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" diff --git a/backend/tests/baserow/contrib/database/view/test_view_types.py b/backend/tests/baserow/contrib/database/view/test_view_types.py index d0bf671ff..43f39ec18 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_types.py +++ b/backend/tests/baserow/contrib/database/view/test_view_types.py @@ -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() diff --git a/premium/backend/src/baserow_premium/apps.py b/premium/backend/src/baserow_premium/apps.py index 5a009e560..9919fb091 100644 --- a/premium/backend/src/baserow_premium/apps.py +++ b/premium/backend/src/baserow_premium/apps.py @@ -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 diff --git a/premium/backend/src/baserow_premium/views/decorator_value_provider_types.py b/premium/backend/src/baserow_premium/views/decorator_value_provider_types.py new file mode 100644 index 000000000..aff63a264 --- /dev/null +++ b/premium/backend/src/baserow_premium/views/decorator_value_provider_types.py @@ -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) diff --git a/premium/backend/tests/baserow_premium/views/test_premium_view_handler.py b/premium/backend/tests/baserow_premium/views/test_premium_view_handler.py index 5e59b61a9..148933a0d 100644 --- a/premium/backend/tests/baserow_premium/views/test_premium_view_handler.py +++ b/premium/backend/tests/baserow_premium/views/test_premium_view_handler.py @@ -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": []}, + ] + } diff --git a/premium/backend/tests/baserow_premium/views/test_premium_view_types.py b/premium/backend/tests/baserow_premium/views/test_premium_view_types.py index b8202b49f..d86117b8a 100644 --- a/premium/backend/tests/baserow_premium/views/test_premium_view_types.py +++ b/premium/backend/tests/baserow_premium/views/test_premium_view_types.py @@ -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) diff --git a/premium/web-frontend/modules/baserow_premium/assets/scss/components/all.scss b/premium/web-frontend/modules/baserow_premium/assets/scss/components/all.scss index c33b828b2..9b3f804af 100644 --- a/premium/web-frontend/modules/baserow_premium/assets/scss/components/all.scss +++ b/premium/web-frontend/modules/baserow_premium/assets/scss/components/all.scss @@ -9,3 +9,4 @@ @import 'views/kanban'; @import 'views/decorators'; @import 'impersonate_warning'; +@import 'views/conditional_color_value_provider_form'; diff --git a/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/conditional_color_value_provider_form.scss b/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/conditional_color_value_provider_form.scss new file mode 100644 index 000000000..cc0f3f16f --- /dev/null +++ b/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/conditional_color_value_provider_form.scss @@ -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; +} diff --git a/premium/web-frontend/modules/baserow_premium/components/views/ConditionalColorValueProviderForm.vue b/premium/web-frontend/modules/baserow_premium/components/views/ConditionalColorValueProviderForm.vue index ae5fc1ada..6c8cbd246 100644 --- a/premium/web-frontend/modules/baserow_premium/components/views/ConditionalColorValueProviderForm.vue +++ b/premium/web-frontend/modules/baserow_premium/components/views/ConditionalColorValueProviderForm.vue @@ -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> diff --git a/premium/web-frontend/modules/baserow_premium/components/views/SingleSelectColorValueProviderForm.vue b/premium/web-frontend/modules/baserow_premium/components/views/SingleSelectColorValueProviderForm.vue index 4a41e0ee7..961a81a90 100644 --- a/premium/web-frontend/modules/baserow_premium/components/views/SingleSelectColorValueProviderForm.vue +++ b/premium/web-frontend/modules/baserow_premium/components/views/SingleSelectColorValueProviderForm.vue @@ -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 diff --git a/premium/web-frontend/modules/baserow_premium/decoratorValueProviders.js b/premium/web-frontend/modules/baserow_premium/decoratorValueProviders.js index 14fd389a3..66401c71e 100644 --- a/premium/web-frontend/modules/baserow_premium/decoratorValueProviders.js +++ b/premium/web-frontend/modules/baserow_premium/decoratorValueProviders.js @@ -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, + }), + ], + } + } } diff --git a/premium/web-frontend/modules/baserow_premium/locales/en.json b/premium/web-frontend/modules/baserow_premium/locales/en.json index 0cf0f7c77..921e943c2 100644 --- a/premium/web-frontend/modules/baserow_premium/locales/en.json +++ b/premium/web-frontend/modules/baserow_premium/locales/en.json @@ -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" } } diff --git a/web-frontend/modules/core/assets/scss/components/filters.scss b/web-frontend/modules/core/assets/scss/components/filters.scss index 94a173db8..7f35fd21a 100644 --- a/web-frontend/modules/core/assets/scss/components/filters.scss +++ b/web-frontend/modules/core/assets/scss/components/filters.scss @@ -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 { diff --git a/web-frontend/modules/core/components/Picker.vue b/web-frontend/modules/core/components/Picker.vue index a3518cc4c..6648fe442 100644 --- a/web-frontend/modules/core/components/Picker.vue +++ b/web-frontend/modules/core/components/Picker.vue @@ -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> diff --git a/web-frontend/modules/core/directives/sortable.js b/web-frontend/modules/core/directives/sortable.js index da362ced3..f695dfe18 100644 --- a/web-frontend/modules/core/directives/sortable.js +++ b/web-frontend/modules/core/directives/sortable.js @@ -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) diff --git a/web-frontend/modules/database/components/field/FieldRatingSubForm.vue b/web-frontend/modules/database/components/field/FieldRatingSubForm.vue index 64b524030..e352ac9e4 100644 --- a/web-frontend/modules/database/components/field/FieldRatingSubForm.vue +++ b/web-frontend/modules/database/components/field/FieldRatingSubForm.vue @@ -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()" > diff --git a/web-frontend/modules/database/components/view/ViewDecoratorContext.vue b/web-frontend/modules/database/components/view/ViewDecoratorContext.vue index ba11d0c55..0bd930b15 100644 --- a/web-frontend/modules/database/components/view/ViewDecoratorContext.vue +++ b/web-frontend/modules/database/components/view/ViewDecoratorContext.vue @@ -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, diff --git a/web-frontend/modules/database/components/view/ViewFieldConditionsForm.vue b/web-frontend/modules/database/components/view/ViewFieldConditionsForm.vue new file mode 100644 index 000000000..e9c130e1e --- /dev/null +++ b/web-frontend/modules/database/components/view/ViewFieldConditionsForm.vue @@ -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> diff --git a/web-frontend/modules/database/components/view/ViewFilterForm.vue b/web-frontend/modules/database/components/view/ViewFilterForm.vue index e8e97f528..bd0cb4d95 100644 --- a/web-frontend/modules/database/components/view/ViewFilterForm.vue +++ b/web-frontend/modules/database/components/view/ViewFilterForm.vue @@ -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> diff --git a/web-frontend/modules/database/components/view/ViewFilterTypeLinkRow.vue b/web-frontend/modules/database/components/view/ViewFilterTypeLinkRow.vue index 7db8f84a9..902d4fa11 100644 --- a/web-frontend/modules/database/components/view/ViewFilterTypeLinkRow.vue +++ b/web-frontend/modules/database/components/view/ViewFilterTypeLinkRow.vue @@ -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) { diff --git a/web-frontend/modules/database/components/view/grid/GridView.vue b/web-frontend/modules/database/components/view/grid/GridView.vue index 59a050cc5..03e041241 100644 --- a/web-frontend/modules/database/components/view/grid/GridView.vue +++ b/web-frontend/modules/database/components/view/grid/GridView.vue @@ -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: { diff --git a/web-frontend/modules/database/components/view/grid/GridViewRow.vue b/web-frontend/modules/database/components/view/grid/GridViewRow.vue index eebcad7b0..500a3c73b 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRow.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRow.vue @@ -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> diff --git a/web-frontend/modules/database/components/view/grid/GridViewRows.vue b/web-frontend/modules/database/components/view/grid/GridViewRows.vue index fd7a1dfbd..4aba9869e 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRows.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRows.vue @@ -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, }), } diff --git a/web-frontend/modules/database/components/view/grid/GridViewSection.vue b/web-frontend/modules/database/components/view/grid/GridViewSection.vue index 7cc02244d..34ea7ade8 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewSection.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewSection.vue @@ -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, diff --git a/web-frontend/modules/database/services/row.js b/web-frontend/modules/database/services/row.js index 603019790..bea33ab4d 100644 --- a/web-frontend/modules/database/services/row.js +++ b/web-frontend/modules/database/services/row.js @@ -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: {} } diff --git a/web-frontend/modules/database/store/view.js b/web-frontend/modules/database/store/view.js index d68ce5355..f1ed1cb15 100644 --- a/web-frontend/modules/database/store/view.js +++ b/web-frontend/modules/database/store/view.js @@ -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 diff --git a/web-frontend/package.json b/web-frontend/package.json index 6dbbdd0c5..9919e0f09 100644 --- a/web-frontend/package.json +++ b/web-frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewDecoratorContext.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewDecoratorContext.spec.js.snap index 62e1db44a..c98d5fe88 100644 --- a/web-frontend/test/unit/database/components/view/__snapshots__/viewDecoratorContext.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewDecoratorContext.spec.js.snap @@ -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> diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap index 06fe8fc14..c528b5f5d 100644 --- a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap @@ -2,53 +2,7 @@ exports[`ViewFilterForm component Default view filter component 1`] = ` <div> - <div - style="display: none;" - > - <div - class="filters__none" - > - <div - class="filters__none-title" - > - - viewFilterContext.noFilterTitle - - </div> - - <div - class="filters__none-description" - > - - viewFilterContext.noFilterText - - </div> - </div> - </div> - - <div - class="filters_footer" - > - <a - class="filters__add" - > - <i - class="fas fa-plus" - /> - - viewFilterContext.addFilter - </a> - - <!----> - </div> -</div> -`; - -exports[`ViewFilterForm component Full view filter component 1`] = ` -<div> - <div - style="display: none;" - > + <div> <div class="filters__none" > @@ -70,629 +24,651 @@ exports[`ViewFilterForm component Full view filter component 1`] = ` </div> </div> + <div /> + <div - class="filters__item" + class="filters_footer" > <a - class="filters__remove" + class="filters__add" > <i - class="fas fa-times" + class="fas fa-plus" /> + + viewFilterContext.addFilter </a> + <!----> + </div> +</div> +`; + +exports[`ViewFilterForm component Full view filter component 1`] = ` +<div> + <!----> + + <div> <div - class="filters__operator" + class="filters__item" > - <span> - viewFilterContext.where - </span> - - <!----> - - <!----> - - <!----> - </div> - - <div - class="filters__field" - > - <div - class="dropdown dropdown--floating dropdown--tiny" + <a + class="filters__remove" > - <a - class="dropdown__selected" + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <span> + viewFilterContext.where + </span> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + Name - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item active" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + Name - </div> - - <!----> - </a> - </li> - - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Stars - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Flag - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__type" - > + <div - class="dropdown dropdown--floating dropdown--tiny" + class="filters__type" > - <a - class="dropdown__selected" + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + viewFilter.is - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item active" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + viewFilter.is - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.isNot - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.contains - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.containsNot - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.lengthIsLowerThan - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.isEmpty - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.isNotEmpty - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__value" - > - <input - class="input filters__value-input" - type="text" - /> - </div> - </div> - <div - class="filters__item" - > - <a - class="filters__remove" - > - <i - class="fas fa-times" - /> - </a> - - <div - class="filters__operator" - > - <!----> <div - class="dropdown dropdown--floating dropdown--tiny" + class="filters__value" > - <a - class="dropdown__selected" - > - <!----> - - viewFilterContext.and + <input + class="input filters__value-input" + type="text" + /> + </div> + </div> + <div + class="filters__item" + > + <a + class="filters__remove" + > + <i + class="fas fa-times" + /> + </a> - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> + <div + class="filters__operator" + > + <!----> <div - class="dropdown__items hidden" + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - - <ul - class="select__items" + <a + class="dropdown__selected" > - <li - class="select__item active" + <!----> + + viewFilterContext.and + + <i + class="dropdown__toggle-icon fas fa-caret-down" + /> + </a> + + <div + class="dropdown__items hidden" + > + <!----> + + <ul + class="select__items" > - <a - class="select__item-link" + <li + class="select__item active" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + viewFilterContext.and - </div> - - <!----> - </a> - </li> - - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilterContext.or - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> + + <!----> </div> - <!----> - - <!----> - </div> - - <div - class="filters__field" - > <div - class="dropdown dropdown--floating dropdown--tiny" + class="filters__field" > - <a - class="dropdown__selected" + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + Stars - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + Name - </div> - - <!----> - </a> - </li> - - <li - class="select__item active" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Stars - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Flag - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__type" - > + <div - class="dropdown dropdown--floating dropdown--tiny" + class="filters__type" > - <a - class="dropdown__selected" + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + viewFilter.is - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item active" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + viewFilter.is - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.isNot - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.higherThan - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.lowerThan - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__value" - > + <div - class="filters__value-rating" + class="filters__value" > <div - class="rating color--dark-orange editing" + class="filters__value-rating" > - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star" - /> - <i - class="fas rating__star fa-star" - /> - <i - class="fas rating__star fa-star" - /> + <div + class="rating color--dark-orange editing" + > + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star" + /> + <i + class="fas rating__star fa-star" + /> + <i + class="fas rating__star fa-star" + /> + </div> </div> </div> </div> @@ -724,292 +700,270 @@ exports[`ViewFilterForm component Full view filter component 1`] = ` exports[`ViewFilterForm component Test rating filter 1`] = ` <div> - <div - style="display: none;" - > - <div - class="filters__none" - > - <div - class="filters__none-title" - > - - viewFilterContext.noFilterTitle - - </div> - - <div - class="filters__none-description" - > - - viewFilterContext.noFilterText - - </div> - </div> - </div> + <!----> - <div - class="filters__item" - > - <a - class="filters__remove" - > - <i - class="fas fa-times" - /> - </a> - + <div> <div - class="filters__operator" + class="filters__item" > - <span> - viewFilterContext.where - </span> - - <!----> - - <!----> - - <!----> - </div> - - <div - class="filters__field" - > - <div - class="dropdown dropdown--floating dropdown--tiny" + <a + class="filters__remove" > - <a - class="dropdown__selected" + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <span> + viewFilterContext.where + </span> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + Stars - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + Name - </div> - - <!----> - </a> - </li> - - <li - class="select__item active" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Stars - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Flag - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__type" - > + <div - class="dropdown dropdown--floating dropdown--tiny" + class="filters__type" > - <a - class="dropdown__selected" + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + viewFilter.is - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items" > - <li - class="select__item active hover" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active hover" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + viewFilter.is - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.isNot - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.higherThan - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.lowerThan - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__value" - > + <div - class="filters__value-rating" + class="filters__value" > <div - class="rating color--dark-orange editing" + class="filters__value-rating" > - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star" - /> - <i - class="fas rating__star fa-star" - /> - <i - class="fas rating__star fa-star" - /> + <div + class="rating color--dark-orange editing" + > + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star" + /> + <i + class="fas rating__star fa-star" + /> + <i + class="fas rating__star fa-star" + /> + </div> </div> </div> </div> @@ -1041,292 +995,270 @@ exports[`ViewFilterForm component Test rating filter 1`] = ` exports[`ViewFilterForm component Test rating filter 2`] = ` <div> - <div - style="display: none;" - > - <div - class="filters__none" - > - <div - class="filters__none-title" - > - - viewFilterContext.noFilterTitle - - </div> - - <div - class="filters__none-description" - > - - viewFilterContext.noFilterText - - </div> - </div> - </div> + <!----> - <div - class="filters__item" - > - <a - class="filters__remove" - > - <i - class="fas fa-times" - /> - </a> - + <div> <div - class="filters__operator" + class="filters__item" > - <span> - viewFilterContext.where - </span> - - <!----> - - <!----> - - <!----> - </div> - - <div - class="filters__field" - > - <div - class="dropdown dropdown--floating dropdown--tiny" + <a + class="filters__remove" > - <a - class="dropdown__selected" + <i + class="fas fa-times" + /> + </a> + + <div + class="filters__operator" + > + <span> + viewFilterContext.where + </span> + + <!----> + + <!----> + </div> + + <div + class="filters__field" + > + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + Stars - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + Name - </div> - - <!----> - </a> - </li> - - <li - class="select__item active" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + + <li + class="select__item active" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Stars - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + Flag - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__type" - > + <div - class="dropdown dropdown--floating dropdown--tiny" + class="filters__type" > - <a - class="dropdown__selected" + <div + class="dropdown dropdown--floating dropdown--tiny" > - <!----> - + <a + class="dropdown__selected" + > + <!----> + viewFilter.is - <i - class="dropdown__toggle-icon fas fa-caret-down" - /> - </a> - - <div - class="dropdown__items hidden" - > - <div - class="select__search" - > <i - class="select__search-icon fas fa-search" + class="dropdown__toggle-icon fas fa-caret-down" /> - - <input - class="select__search-input" - placeholder="action.search" - type="text" - /> - </div> + </a> - <ul - class="select__items" + <div + class="dropdown__items hidden" > - <li - class="select__item active hover" + <div + class="select__search" > - <a - class="select__item-link" + <i + class="select__search-icon fas fa-search" + /> + + <input + class="select__search-input" + placeholder="action.search" + type="text" + /> + </div> + + <ul + class="select__items" + > + <li + class="select__item active hover" > - <div - class="select__item-name" + <a + class="select__item-link" > - <!----> - + <div + class="select__item-name" + > + <!----> + viewFilter.is - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.isNot - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.higherThan - </div> - - <!----> - </a> - </li> - <li - class="select__item" - > - <a - class="select__item-link" - > - <div - class="select__item-name" - > + </div> + <!----> - + </a> + </li> + <li + class="select__item" + > + <a + class="select__item-link" + > + <div + class="select__item-name" + > + <!----> + viewFilter.lowerThan - </div> - - <!----> - </a> - </li> - </ul> + </div> + + <!----> + </a> + </li> + </ul> + </div> </div> </div> - </div> - - <div - class="filters__value" - > + <div - class="filters__value-rating" + class="filters__value" > <div - class="rating color--dark-orange editing" + class="filters__value-rating" > - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star rating__star--selected" - /> - <i - class="fas rating__star fa-star rating__star--selected" - /> + <div + class="rating color--dark-orange editing" + > + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + <i + class="fas rating__star fa-star rating__star--selected" + /> + </div> </div> </div> </div> diff --git a/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js b/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js index e127a8f6f..ebf85636e 100644 --- a/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js +++ b/web-frontend/test/unit/database/components/view/viewFilterForm.spec.js @@ -93,7 +93,7 @@ describe('ViewFilterForm component', () => { props = { primary: {}, fields: [], - view: { filters: {}, _: {} }, + view: { filters: [], _: {} }, readOnly: false, }, listeners = {}