diff --git a/backend/src/baserow/contrib/database/api/views/grid/views.py b/backend/src/baserow/contrib/database/api/views/grid/views.py index 19977bbab..e73c93625 100644 --- a/backend/src/baserow/contrib/database/api/views/grid/views.py +++ b/backend/src/baserow/contrib/database/api/views/grid/views.py @@ -672,6 +672,15 @@ class PublicGridViewRowsView(APIView): "order, but by prepending the field with a '-' it can be ordered " "descending (Z-A).", ), + OpenApiParameter( + name="group_by", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description="Optionally the rows can be grouped by provided field ids " + "separated by comma. By default no groups are applied. This doesn't " + "actually responds with the rows groups, this is just what's needed " + "for the Baserow group by feature.", + ), OpenApiParameter( name="filters", location=OpenApiParameter.QUERY, @@ -823,6 +832,7 @@ class PublicGridViewRowsView(APIView): search = query_params.get("search") search_mode = query_params.get("search_mode") order_by = request.GET.get("order_by") + group_by = request.GET.get("group_by") include_fields = request.GET.get("include_fields") exclude_fields = request.GET.get("exclude_fields") filter_type = ( @@ -859,6 +869,7 @@ class PublicGridViewRowsView(APIView): search=search, search_mode=search_mode, order_by=order_by, + group_by=group_by, include_fields=include_fields, exclude_fields=exclude_fields, filter_type=filter_type, diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index f46de5722..ffc654e22 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -3159,6 +3159,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)): search: str = None, search_mode: Optional[SearchModes] = None, order_by: str = None, + group_by: str = None, include_fields: str = None, exclude_fields: str = None, filter_type: str = None, @@ -3179,6 +3180,7 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)): :param search: A string to search for in the rows. :param search_mode: The type of search to perform. :param order_by: A string to order the rows by. + :param group_by: A string group the rows by. :param include_fields: A comma separated list of field_ids to include. :param exclude_fields: A comma separated list of field_ids to exclude. :param filter_type: The type of filter to apply. @@ -3222,7 +3224,19 @@ class ViewHandler(metaclass=baserow_trace_methods(tracer)): queryset = table_model.objects.all().enhance_by_fields() queryset = self.apply_filters(view, queryset) - if order_by: + if view_type.can_group_by: + # If both the group by and order by string is set, then we must merge the + # two so that it will be sorted the right way because the grouping is + # basically just sorting for the backend. However, the group by will take + # precedence. + if group_by is not None and order_by is not None: + order_by = f"{group_by},{order_by}" + # If only the group_by is set, then we can simply replace the order_by + # because that must be applied to the queryset. + elif group_by is not None: + order_by = group_by + + if order_by is not None: queryset = queryset.order_by_fields_string( order_by, False, publicly_visible_field_ids ) diff --git a/backend/templates/all-fields.json b/backend/templates/all-fields.json new file mode 100644 index 000000000..f653ec6ec --- /dev/null +++ b/backend/templates/all-fields.json @@ -0,0 +1,877 @@ +{ + "baserow_template_version": 1, + "name": "All fields", + "icon": "iconoir-input-field", + "keywords": [ + "All", + "Fields", + "Baserow" + ], + "categories": [ + "Baserow" + ], + "export": [ + { + "id": 131, + "name": "All field types", + "order": 1, + "type": "database", + "tables": [ + { + "id": 538, + "name": "All field types", + "order": 1, + "fields": [ + { + "id": 4664, + "type": "text", + "name": "Name", + "order": 0, + "primary": true, + "text_default": "" + }, + { + "id": 4665, + "type": "long_text", + "name": "Notes", + "order": 1, + "primary": false + }, + { + "id": 4666, + "type": "number", + "name": "Number", + "order": 2, + "primary": false, + "number_decimal_places": 0, + "number_negative": false + }, + { + "id": 4667, + "type": "rating", + "name": "Rating", + "order": 3, + "primary": false, + "max_value": 5, + "color": "dark-orange", + "style": "star" + }, + { + "id": 4668, + "type": "boolean", + "name": "Boolean", + "order": 4, + "primary": false + }, + { + "id": 4669, + "type": "date", + "name": "Date european", + "order": 5, + "primary": false, + "date_format": "EU", + "date_include_time": false, + "date_time_format": "24", + "date_show_tzinfo": false, + "date_force_timezone": null + }, + { + "id": 4670, + "type": "date", + "name": "Date us include time", + "order": 6, + "primary": false, + "date_format": "US", + "date_include_time": true, + "date_time_format": "12", + "date_show_tzinfo": false, + "date_force_timezone": null + }, + { + "id": 4671, + "type": "last_modified", + "name": "Last modified", + "order": 7, + "primary": false, + "date_format": "EU", + "date_include_time": true, + "date_time_format": "24", + "date_show_tzinfo": true, + "date_force_timezone": null + }, + { + "id": 4672, + "type": "created_on", + "name": "Created on", + "order": 8, + "primary": false, + "date_format": "EU", + "date_include_time": true, + "date_time_format": "24", + "date_show_tzinfo": true, + "date_force_timezone": null + }, + { + "id": 4673, + "type": "url", + "name": "URL", + "order": 9, + "primary": false + }, + { + "id": 4674, + "type": "email", + "name": "Email", + "order": 10, + "primary": false + }, + { + "id": 4675, + "type": "file", + "name": "File", + "order": 11, + "primary": false + }, + { + "id": 4676, + "type": "single_select", + "name": "Single select", + "order": 12, + "primary": false, + "select_options": [ + { + "id": 2492, + "value": "Option 1", + "color": "green", + "order": 0 + }, + { + "id": 2493, + "value": "Option 2", + "color": "cyan", + "order": 1 + } + ] + }, + { + "id": 4677, + "type": "multiple_select", + "name": "Multiple select", + "order": 13, + "primary": false, + "select_options": [ + { + "id": 2494, + "value": "Option A", + "color": "red", + "order": 0 + }, + { + "id": 2495, + "value": "Option B", + "color": "green", + "order": 1 + }, + { + "id": 2496, + "value": "Option C", + "color": "blue", + "order": 2 + } + ] + }, + { + "id": 4678, + "type": "phone_number", + "name": "Phone number", + "order": 14, + "primary": false + }, + { + "id": 4679, + "type": "formula", + "name": "Formula text", + "order": 15, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": true, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": null, + "date_time_format": null, + "array_formula_type": null, + "formula": "field(\"Name\")", + "formula_type": "text" + }, + { + "id": 4680, + "type": "formula", + "name": "Formula file", + "order": 16, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": true, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": null, + "date_time_format": null, + "array_formula_type": "single_file", + "formula": "field(\"File\")", + "formula_type": "array" + }, + { + "id": 4686, + "type": "link_row", + "name": "Related", + "order": 17, + "primary": false, + "link_row_table_id": 539, + "link_row_related_field_id": null, + "has_related_field": false + }, + { + "id": 4687, + "type": "lookup", + "name": "Lookup text", + "order": 18, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": true, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": null, + "date_time_format": null, + "array_formula_type": "text", + "through_field_id": 4686, + "through_field_name": "Related", + "target_field_id": 4681, + "target_field_name": "Name" + }, + { + "id": 4688, + "type": "lookup", + "name": "Lookup number", + "order": 19, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": true, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": 0, + "date_time_format": null, + "array_formula_type": "number", + "through_field_id": 4686, + "through_field_name": "Related", + "target_field_id": 4684, + "target_field_name": "Number" + }, + { + "id": 4689, + "type": "lookup", + "name": "Lookup single select", + "order": 20, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": true, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": null, + "date_time_format": null, + "array_formula_type": "single_select", + "through_field_id": 4686, + "through_field_name": "Related", + "target_field_id": 4685, + "target_field_name": "Single select" + }, + { + "id": 4691, + "type": "lookup", + "name": "Lookup", + "order": 21, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": false, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": null, + "date_time_format": null, + "array_formula_type": "boolean", + "through_field_id": 4686, + "through_field_name": "Related", + "target_field_id": 4690, + "target_field_name": "Boolean" + }, + { + "id": 4693, + "type": "lookup", + "name": "Lookup date", + "order": 22, + "primary": false, + "error": null, + "date_include_time": false, + "nullable": true, + "date_show_tzinfo": false, + "date_format": "EU", + "date_force_timezone": null, + "number_decimal_places": null, + "date_time_format": "24", + "array_formula_type": "date", + "through_field_id": 4686, + "through_field_name": "Related", + "target_field_id": 4692, + "target_field_name": "Date" + }, + { + "id": 4694, + "type": "count", + "name": "Count", + "order": 23, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": false, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": 0, + "date_time_format": null, + "array_formula_type": null, + "through_field_id": 4686 + }, + { + "id": 4695, + "type": "rollup", + "name": "Rollup sum", + "order": 24, + "primary": false, + "error": null, + "date_include_time": null, + "nullable": false, + "date_show_tzinfo": null, + "date_format": null, + "date_force_timezone": null, + "number_decimal_places": 0, + "date_time_format": null, + "array_formula_type": null, + "through_field_id": 4686, + "target_field_id": 4684, + "rollup_function": "sum" + }, + { + "id": 4696, + "type": "multiple_collaborators", + "name": "Collaborators", + "order": 25, + "primary": false, + "notify_user_when_added": false + }, + { + "id": 4697, + "type": "number", + "name": "Price", + "order": 26, + "primary": false, + "number_decimal_places": 2, + "number_negative": false + } + ], + "views": [ + { + "id": 2169, + "type": "grid", + "name": "Grid", + "order": 1, + "ownership_type": "collaborative", + "created_by": "bram@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "count", + "field_options": [ + { + "id": 14321, + "field_id": 4664, + "width": 148, + "hidden": false, + "order": 0, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14319, + "field_id": 4665, + "width": 191, + "hidden": false, + "order": 1, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14320, + "field_id": 4666, + "width": 125, + "hidden": false, + "order": 2, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14351, + "field_id": 4697, + "width": 200, + "hidden": false, + "order": 3, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14341, + "field_id": 4686, + "width": 200, + "hidden": false, + "order": 4, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14322, + "field_id": 4667, + "width": 200, + "hidden": false, + "order": 5, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14323, + "field_id": 4668, + "width": 200, + "hidden": false, + "order": 6, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14324, + "field_id": 4669, + "width": 200, + "hidden": false, + "order": 7, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14325, + "field_id": 4670, + "width": 200, + "hidden": false, + "order": 8, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14326, + "field_id": 4671, + "width": 216, + "hidden": false, + "order": 9, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14327, + "field_id": 4672, + "width": 236, + "hidden": false, + "order": 10, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14333, + "field_id": 4673, + "width": 200, + "hidden": false, + "order": 11, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14334, + "field_id": 4674, + "width": 200, + "hidden": false, + "order": 12, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14335, + "field_id": 4675, + "width": 200, + "hidden": false, + "order": 13, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14336, + "field_id": 4676, + "width": 200, + "hidden": false, + "order": 14, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14337, + "field_id": 4677, + "width": 200, + "hidden": false, + "order": 15, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14338, + "field_id": 4678, + "width": 200, + "hidden": false, + "order": 16, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14348, + "field_id": 4694, + "width": 200, + "hidden": false, + "order": 17, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14349, + "field_id": 4695, + "width": 200, + "hidden": false, + "order": 18, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14339, + "field_id": 4679, + "width": 200, + "hidden": false, + "order": 19, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14340, + "field_id": 4680, + "width": 200, + "hidden": false, + "order": 20, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14342, + "field_id": 4687, + "width": 200, + "hidden": false, + "order": 21, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14343, + "field_id": 4688, + "width": 200, + "hidden": false, + "order": 22, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14344, + "field_id": 4689, + "width": 200, + "hidden": false, + "order": 23, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14346, + "field_id": 4691, + "width": 200, + "hidden": false, + "order": 24, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14347, + "field_id": 4693, + "width": 200, + "hidden": false, + "order": 25, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14350, + "field_id": 4696, + "width": 200, + "hidden": false, + "order": 26, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + } + ], + "rows": [ + { + "id": 1, + "order": "1.00000000000000000000", + "created_on": "2023-10-25T19:04:55.554233+00:00", + "updated_on": "2023-10-25T19:14:16.559135+00:00", + "field_4664": "First row", + "field_4665": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. In pretium sem id tellus ultricies fringilla. Vivamus vel lorem et lacus luctus pretium. Vestibulum ut tellus at ipsum faucibus venenatis sed vel diam. Cras sit amet leo neque. Nulla efficitur sit amet sapien et hendrerit. Praesent eu elit eu lorem consequat porta molestie in dui. Sed pellentesque urna ipsum, posuere tristique mauris vestibulum ac. Nam malesuada nisl non risus condimentum rutrum. Aliquam suscipit, lectus ut euismod euismod, nibh justo dictum ligula, nec lacinia augue nisi vel arcu.", + "field_4666": "100", + "field_4667": 4, + "field_4668": "true", + "field_4669": "2023-10-01", + "field_4670": "2023-10-02T19:11:26+00:00", + "field_4671": null, + "field_4672": null, + "field_4673": "https://baserow.io", + "field_4674": "bram@baserow.io", + "field_4675": [ + { + "name": "1eOyNhZSuyOoGtr7cIuKnqzzofEQLv3N_45e07cf623e634b96b3e82aeaf7ecc74efd03a05808bf1eb69ceef8a9df7d135.doc", + "visible_name": "file-sample_100kB.doc", + "original_name": "file-sample_100kB.doc" + }, + { + "name": "NPE5TiPZyWUiep1sav7XmQpIb5TDKWvf_771b2e98db13f1f683eaf37dd65364a9297ad32679301947f4bea68c3e625244.xls", + "visible_name": "file_example_XLS_10.xls", + "original_name": "file_example_XLS_10.xls" + }, + { + "name": "cFVB2dLV1KGLadXeS6L74H0FXQWvHjQX_71944d7430c461f0cd6e7fd10cee7eb72786352a3678fc7bc0ae3d410f72aece.mp4", + "visible_name": "file_example_MP4_480_1_5MG.mp4", + "original_name": "file_example_MP4_480_1_5MG.mp4" + }, + { + "name": "ZSIWNqJTojLUEvo91Qr4IQVfhosEdUy6_9a2270d5964f64981fb1e91dd13e5941262817bdce873cf357c92adbef906b5d.mp3", + "visible_name": "file_example_MP3_700KB.mp3", + "original_name": "file_example_MP3_700KB.mp3" + }, + { + "name": "ZommpPn0tOFo8q8ISZON8YBGEIGGM8Yx_88aeb1f4467bd1e50cf624de972fbf3f40801632fedb64aaa7b1a8a9ef786fc6.jpg", + "visible_name": "file_example_JPG_100kB.jpg", + "original_name": "file_example_JPG_100kB.jpg" + }, + { + "name": "IyUZDvME0a9RreFstg2Zc7nBTgl46zQd_38c9792d725c45dd431699e6a3b0f0f8e17c63c9ac7331387ee30dcc6e42a511.pdf", + "visible_name": "file-sample_150kB.pdf", + "original_name": "file-sample_150kB.pdf" + } + ], + "field_4676": 2492, + "field_4677": [ + 2494, + 2495 + ], + "field_4678": "+12345678", + "field_4679": null, + "field_4680": null, + "field_4686": [ + 1, + 2 + ], + "field_4687": null, + "field_4688": null, + "field_4689": null, + "field_4691": null, + "field_4693": null, + "field_4694": null, + "field_4695": null, + "field_4696": [ + "bram@baserow.io" + ], + "field_4697": "100.00" + } + ] + }, + { + "id": 539, + "name": "Related", + "order": 2, + "fields": [ + { + "id": 4681, + "type": "text", + "name": "Name", + "order": 0, + "primary": true, + "text_default": "" + }, + { + "id": 4684, + "type": "number", + "name": "Number", + "order": 1, + "primary": false, + "number_decimal_places": 0, + "number_negative": false + }, + { + "id": 4685, + "type": "single_select", + "name": "Single select", + "order": 2, + "primary": false, + "select_options": [ + { + "id": 2497, + "value": "1", + "color": "dark-orange", + "order": 0 + }, + { + "id": 2498, + "value": "2", + "color": "green", + "order": 1 + } + ] + }, + { + "id": 4690, + "type": "boolean", + "name": "Boolean", + "order": 3, + "primary": false + }, + { + "id": 4692, + "type": "date", + "name": "Date", + "order": 4, + "primary": false, + "date_format": "EU", + "date_include_time": true, + "date_time_format": "24", + "date_show_tzinfo": false, + "date_force_timezone": null + } + ], + "views": [ + { + "id": 2170, + "type": "grid", + "name": "Grid", + "order": 1, + "ownership_type": "collaborative", + "created_by": "bram@baserow.io", + "filter_type": "AND", + "filters_disabled": false, + "filters": [], + "filter_groups": [], + "sortings": [], + "group_bys": [], + "decorations": [], + "public": false, + "row_identifier_type": "id", + "field_options": [ + { + "id": 14330, + "field_id": 4681, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14331, + "field_id": 4684, + "width": 184, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14332, + "field_id": 4685, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + }, + { + "id": 14345, + "field_id": 4690, + "width": 200, + "hidden": false, + "order": 32767, + "aggregation_type": "", + "aggregation_raw_type": "" + } + ] + } + ], + "rows": [ + { + "id": 1, + "order": "1.00000000000000000000", + "created_on": "2023-10-25T19:09:50.671242+00:00", + "updated_on": "2023-10-25T19:12:42.182580+00:00", + "field_4681": "Row 1", + "field_4684": "1", + "field_4685": 2497, + "field_4690": "true", + "field_4692": "2023-10-01T19:12:41+00:00" + }, + { + "id": 2, + "order": "2.00000000000000000000", + "created_on": "2023-10-25T19:09:50.671294+00:00", + "updated_on": "2023-10-25T19:12:45.338599+00:00", + "field_4681": "Row 2", + "field_4684": "2", + "field_4685": 2498, + "field_4690": "true", + "field_4692": "2023-10-02T19:12:44+00:00" + } + ] + } + ] + } + ] +} diff --git a/backend/templates/all-fields.zip b/backend/templates/all-fields.zip new file mode 100644 index 000000000..e64b27832 Binary files /dev/null and b/backend/templates/all-fields.zip differ diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py index a032edf41..7e62b7231 100644 --- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py @@ -2277,6 +2277,95 @@ def test_list_rows_public_with_query_param_order(api_client, data_fixture): assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_POSSIBLE" +@pytest.mark.django_db +def test_list_rows_public_with_query_param_group_by(api_client, data_fixture): + user, token = data_fixture.create_user_and_token() + table = data_fixture.create_database_table(user=user) + table_2 = data_fixture.create_database_table(database=table.database) + public_field = data_fixture.create_text_field(table=table, name="public") + public_field_2 = data_fixture.create_text_field(table=table, name="public2") + hidden_field = data_fixture.create_text_field(table=table, name="hidden") + link_row_field = FieldHandler().create_field( + user=user, + table=table, + type_name="link_row", + name="Link", + link_row_table=table_2, + ) + grid_view = data_fixture.create_grid_view( + table=table, user=user, public=True, create_options=False + ) + data_fixture.create_grid_view_field_option(grid_view, public_field, hidden=False) + data_fixture.create_grid_view_field_option(grid_view, public_field_2, hidden=False) + data_fixture.create_grid_view_field_option(grid_view, hidden_field, hidden=True) + data_fixture.create_grid_view_field_option(grid_view, link_row_field, hidden=False) + + first_row = RowHandler().create_row( + user, + table, + values={"public": "b", "public2": "2", "hidden": "y"}, + user_field_names=True, + ) + second_row = RowHandler().create_row( + user, + table, + values={"public": "a", "public2": "2", "hidden": "y"}, + user_field_names=True, + ) + third_row = RowHandler().create_row( + user, + table, + values={"public": "b", "public2": "1", "hidden": "z"}, + user_field_names=True, + ) + + url = reverse( + "api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug} + ) + response = api_client.get( + f"{url}?group_by=field_{public_field.id}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert len(response_json["results"]) == 3 + assert response_json["results"][0]["id"] == second_row.id + assert response_json["results"][1]["id"] == first_row.id + assert response_json["results"][2]["id"] == third_row.id + + url = reverse( + "api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug} + ) + response = api_client.get( + f"{url}?group_by=-field_{public_field.id}&sort_by=field{public_field_2.id}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + assert len(response_json["results"]) == 3 + assert response_json["results"][0]["id"] == first_row.id + assert response_json["results"][1]["id"] == third_row.id + assert response_json["results"][2]["id"] == second_row.id + + url = reverse( + "api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug} + ) + response = api_client.get( + f"{url}?group_by=field_{hidden_field.id}", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND" + + url = reverse( + "api:database:views:grid:public_rows", kwargs={"slug": grid_view.slug} + ) + response = api_client.get( + f"{url}?group_by=field_{link_row_field.id}", + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_POSSIBLE" + + @pytest.mark.django_db def test_list_rows_public_filters_by_visible_and_hidden_columns( api_client, data_fixture diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py index 28b889429..9b046bf0d 100755 --- a/backend/tests/baserow/contrib/database/view/test_view_handler.py +++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py @@ -2204,6 +2204,51 @@ def test_get_public_rows_queryset_and_field_ids_view_order_by(data_fixture): assert list(queryset.values_list(f"field_{field.id}", flat=True)) == [1, 2, 3] +@pytest.mark.django_db +def test_get_public_rows_queryset_and_field_ids_view_group_by(data_fixture): + grid_view = data_fixture.create_grid_view(public=True) + field = data_fixture.create_number_field(table=grid_view.table) + field_2 = data_fixture.create_number_field(table=grid_view.table) + + model = grid_view.table.get_model() + row_1 = model.objects.create(**{f"field_{field.id}": 1, f"field_{field_2.id}": 4}) + row_2 = model.objects.create(**{f"field_{field.id}": 2, f"field_{field_2.id}": 3}) + row_3 = model.objects.create(**{f"field_{field.id}": 3, f"field_{field_2.id}": 2}) + row_4 = model.objects.create(**{f"field_{field.id}": 3, f"field_{field_2.id}": 1}) + + ( + queryset, + field_ids, + publicly_visible_field_options, + ) = ViewHandler().get_public_rows_queryset_and_field_ids( + grid_view, group_by=f"-field_{field.id}" + ) + + assert queryset.count() == 4 + assert list(queryset.values_list("pk", flat=True)) == [ + row_3.id, + row_4.id, + row_2.id, + row_1.id, + ] + + ( + queryset, + field_ids, + publicly_visible_field_options, + ) = ViewHandler().get_public_rows_queryset_and_field_ids( + grid_view, group_by=f"field_{field.id}", order_by=f"field_{field_2.id}" + ) + + assert queryset.count() == 4 + assert list(queryset.values_list("pk", flat=True)) == [ + row_1.id, + row_2.id, + row_4.id, + row_3.id, + ] + + @pytest.mark.django_db def test_get_public_rows_queryset_and_field_ids_include_exclude_fields(data_fixture): grid_view = data_fixture.create_grid_view(public=True) diff --git a/changelog/entries/unreleased/feature/143_group_rows_by_field.json b/changelog/entries/unreleased/feature/143_group_rows_by_field.json new file mode 100644 index 000000000..7aa005d8b --- /dev/null +++ b/changelog/entries/unreleased/feature/143_group_rows_by_field.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Group rows by field.", + "issue_number": 143, + "bullet_points": [], + "created_at": "2023-10-27" +} diff --git a/web-frontend/modules/core/assets/scss/components/group_bys.scss b/web-frontend/modules/core/assets/scss/components/group_bys.scss index 7a1f71f52..911c53c7e 100644 --- a/web-frontend/modules/core/assets/scss/components/group_bys.scss +++ b/web-frontend/modules/core/assets/scss/components/group_bys.scss @@ -23,8 +23,6 @@ .group-bys__item { position: relative; - display: flex; - align-items: center; padding: 6px 0; @include rounded($rounded); @include flex-align-items(10px); @@ -41,7 +39,7 @@ margin-top: -7px; @include loading(14px); - @include absolute(50%, auto, 0, 10px); + @include absolute(50%, auto, 0, 5px); } } } @@ -65,8 +63,8 @@ } .group-bys__description { - flex: 0 0 60px; - width: 60px; + flex: 0 0 50px; + width: 50px; margin-right: 10px; span { diff --git a/web-frontend/modules/core/assets/scss/components/views/grid.scss b/web-frontend/modules/core/assets/scss/components/views/grid.scss index 47416d0ad..d01a9a9b8 100644 --- a/web-frontend/modules/core/assets/scss/components/views/grid.scss +++ b/web-frontend/modules/core/assets/scss/components/views/grid.scss @@ -118,6 +118,10 @@ } } +.grid-view__head-group { + width: 100px; // must be replaced with dynamic value later. +} + .grid-view__body { position: absolute; left: 0; @@ -176,11 +180,18 @@ background-size: 66px 66px; } +.grid-view__placeholder-groups { + @include absolute(0, auto, 0, 0); + + background-color: $color-neutral-50; +} + .grid-view__placeholder-column { @include absolute(0, auto, 0, auto); border-right: 1px solid $color-neutral-200; + &.grid-view__placeholder-column--no-border-right, .grid-view__left & { border-right: none; } @@ -229,6 +240,11 @@ } } +.grid-view__row-placeholder { + height: 32px + 1px; + width: 1px; +} + .grid-view__row-warning { @include absolute(auto, auto, -20px, 0); @include fixed-height(20px, 12px); @@ -257,11 +273,13 @@ border-bottom: 0; } + &.grid-view__column--no-border-right, .grid-view__left & { border-right: none; } &.grid-view__column--sorted::after, + &.grid-view__column--grouped::after, &.grid-view__column--filtered::after { content: ''; @@ -272,9 +290,70 @@ background-color: rgba($color-warning-100, 0.8); } + &.grid-view__column--grouped::after { + background-color: rgba($color-purple-100, 0.5); + } + &.grid-view__column--filtered::after { background-color: rgba($color-success-100, 0.5); } + + &.grid-view__column--group-end::before { + @include absolute(auto, auto, -1px, 0); + + content: ''; + height: 1px; + background-color: $color-neutral-400; + width: 100%; + pointer-events: none; + } +} + +.grid-view__group-by-divider { + @include absolute(0, auto, 0, auto); + + z-index: 3; + width: 1px; + background-color: $color-neutral-400; +} + +.grid-view__group { + position: relative; + background-color: $color-neutral-50; + + &.grid-view__group--end::after { + @include absolute(auto, auto, 0, 0); + + content: ''; + height: 1px; + background-color: $color-neutral-400; + width: 100%; + pointer-events: none; + } +} + +.grid-view__group-cell { + display: flex; + height: 33px; + align-items: center; + width: 100%; + padding: 0 8px; +} + +.grid-view__group-name { + @extend %ellipsis; + + max-width: 50%; + line-height: 33px; + font-size: 12px; + font-weight: 600; + flex-shrink: 0; +} + +.grid-view__group-value { + min-width: 0; + width: 100%; + margin-left: 8px; } .grid-view__row-info { @@ -581,6 +660,7 @@ .grid-view__add-row { color: $color-neutral-900; background-color: $white; + padding-left: 8px; height: 100%; @include flex-align-items(); @@ -592,10 +672,6 @@ &.hover { background-color: $color-primary-100; } - - .grid-view__left & { - padding-left: 8px; - } } .grid-view__field-dragging { diff --git a/web-frontend/modules/database/components/view/grid/GridView.vue b/web-frontend/modules/database/components/view/grid/GridView.vue index 0b90f2a40..6202c1e54 100644 --- a/web-frontend/modules/database/components/view/grid/GridView.vue +++ b/web-frontend/modules/database/components/view/grid/GridView.vue @@ -15,14 +15,16 @@ <GridViewSection ref="left" class="grid-view__left" - :fields="leftFields" + :visible-fields="leftFields" + :all-fields-in-table="fields" :decorations-by-place="decorationsByPlace" :database="database" :table="table" :view="view" :include-field-width-handles="false" - :include-row-details="true" - :include-grid-view-identifier-dropdown="true" + :include-row-details="!viewHasGroupBys" + :include-grid-view-identifier-dropdown="!viewHasGroupBys" + :include-group-by="true" :read-only=" readOnly || !$hasPermission( @@ -55,13 +57,7 @@ @refresh-row="refreshRow" @scroll="scroll($event.pixelY, 0)" @cell-selected="cellSelected" - > - <template #foot> - <div class="grid-view__foot-info"> - {{ $tc('gridView.rowCount', count, { count }) }} - </div> - </template> - </GridViewSection> + ></GridViewSection> <GridViewRowsAddContext ref="rowsAddContext" @add-rows="addRows" /> <div ref="divider" @@ -69,7 +65,7 @@ :style="{ left: leftWidth + 'px' }" ></div> <GridViewFieldWidthHandle - v-if="canFitInTwoColumns" + v-if="primaryFieldIsSticky" class="grid-view__divider-width" :style="{ left: leftWidth + 'px' }" :database="database" @@ -85,12 +81,15 @@ <GridViewSection ref="right" class="grid-view__right" - :fields="visibleFields" + :visible-fields="rightVisibleFields" + :all-fields-in-table="fields" :decorations-by-place="decorationsByPlace" :database="database" - :can-fit-in-two-columns="canFitInTwoColumns" + :primary-field-is-sticky="primaryFieldIsSticky" :table="table" :view="view" + :include-row-details="viewHasGroupBys" + :include-grid-view-identifier-dropdown="viewHasGroupBys" :include-add-field="true" :can-order-fields="true" :read-only=" @@ -113,6 +112,7 @@ @update="updateValue" @paste="multiplePasteFromCell" @edit="editValue" + @row-dragging="rowDragStart" @cell-mousedown-left="multiSelectStart" @cell-mouseover="multiSelectHold" @cell-mouseup-left="multiSelectStop" @@ -124,14 +124,14 @@ @refresh-row="refreshRow" @scroll="scroll($event.pixelY, $event.pixelX)" @cell-selected="cellSelected" - > - </GridViewSection> + ></GridViewSection> <GridViewRowDragging ref="rowDragging" :table="table" :view="view" :fields="allVisibleFields" :store-prefix="storePrefix" + :offset="activeGroupBys.length * groupWidth" vertical="getVerticalScrollbarElement" @scroll="scroll($event.pixelY, $event.pixelX)" ></GridViewRowDragging> @@ -375,14 +375,14 @@ export default { * belong. */ allVisibleFields() { - return this.leftFields.concat(this.visibleFields) + return this.leftFields.concat(this.rightVisibleFields) }, /** * Returns only the visible fields in the correct order that are in * the right section of the grid. Primary must always be * first if in that list. */ - visibleFields() { + rightVisibleFields() { const fieldOptions = this.fieldOptions return this.rightFields .filter(filterVisibleFieldsFunction(fieldOptions)) @@ -397,15 +397,21 @@ export default { .filter(filterHiddenFieldsFunction(fieldOptions)) .sort(sortFieldsByOrderAndIdFunction(fieldOptions)) }, + viewHasGroupBys() { + return this.activeGroupBys.length > 0 + }, + primaryFieldIsSticky() { + return this.canFitInTwoColumns && !this.viewHasGroupBys + }, leftFields() { - if (this.canFitInTwoColumns) { + if (this.primaryFieldIsSticky) { return this.fields.filter((field) => field.primary) } else { return [] } }, rightFields() { - if (this.canFitInTwoColumns) { + if (this.primaryFieldIsSticky) { return this.fields.filter((field) => !field.primary) } else { return this.fields @@ -418,7 +424,12 @@ export default { ) }, leftWidth() { - return this.leftFieldsWidth + this.gridViewRowDetailsWidth + return ( + this.leftFieldsWidth + + (this.viewHasGroupBys ? 0 : this.gridViewRowDetailsWidth) + + // 100 must be replaced with the dynamic width + this.activeGroupBys.length * this.groupWidth + ) }, activeSearchTerm() { return this.$store.getters[ @@ -467,7 +478,6 @@ export default { ...(this.$options.computed || {}), ...mapGetters({ allRows: this.$options.propsData.storePrefix + 'view/grid/getAllRows', - count: this.$options.propsData.storePrefix + 'view/grid/getCount', isMultiSelectActive: this.$options.propsData.storePrefix + 'view/grid/isMultiSelectActive', }), @@ -572,7 +582,7 @@ export default { if (scrollDirection !== 'vertical') { const fieldPrimary = field.primary - if (elementLeft < 0 && (!this.canFitInTwoColumns || !fieldPrimary)) { + if (elementLeft < 0 && (!this.primaryFieldIsSticky || !fieldPrimary)) { // If the field isn't visible in the viewport we need to scroll left in order // to show it. this.horizontalScroll( @@ -581,7 +591,7 @@ export default { this.$refs.scrollbars.updateHorizontal() } else if ( elementRight > horizontalContainerWidth && - (!this.canFitInTwoColumns || !fieldPrimary) + (!this.primaryFieldIsSticky || !fieldPrimary) ) { // If the field isn't visible in the viewport we need to scroll right in order // to show it. @@ -635,10 +645,13 @@ export default { const filterIndex = this.view.filters.findIndex((filter) => { return filter.field === field.id }) + const groupIndex = this.view.group_bys.findIndex((group) => { + return group.field === field.id + }) const sortIndex = this.view.sortings.findIndex((sort) => { return sort.field === field.id }) - if (filterIndex > -1 || sortIndex > -1) { + if (filterIndex > -1 || groupIndex > -1 || sortIndex > -1) { this.$emit('refresh') } }, @@ -1076,6 +1089,13 @@ export default { this.storePrefix + 'view/grid/visibleByScrollTop', this.$refs.right.$refs.body.scrollTop ) + // The grid view store keeps a copy of the group bys that must only be updated + // after the refresh of the page. This is because the group by depends on the rows + // being sorted, and this will only be the case after a refresh. + await this.$store.dispatch( + this.storePrefix + 'view/grid/updateActiveGroupBys', + clone(this.view.group_bys) + ) this.$nextTick(() => { this.fieldsUpdated() }) diff --git a/web-frontend/modules/database/components/view/grid/GridViewCell.vue b/web-frontend/modules/database/components/view/grid/GridViewCell.vue index 038e2181a..3e1ea2f41 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewCell.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewCell.vue @@ -22,6 +22,7 @@ 'grid-view__column--multi-select-left': props.multiSelectPosition.left, 'grid-view__column--multi-select-bottom': props.multiSelectPosition.bottom, + 'grid-view__column--group-end': props.groupEnd, }" :style="data.style" @click.exact="$options.methods.select($event, parent, props.field.id)" diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue index 3b785a00d..84637ec46 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue @@ -5,6 +5,9 @@ 'grid-view__column--filtered': !view.filters_disabled && view.filters.findIndex((filter) => filter.field === field.id) !== -1, + 'grid-view__column--grouped': + view.group_bys.findIndex((groupBy) => groupBy.field === field.id) !== + -1, 'grid-view__column--sorted': view.sortings.findIndex((sort) => sort.field === field.id) !== -1, }" diff --git a/web-frontend/modules/database/components/view/grid/GridViewHead.vue b/web-frontend/modules/database/components/view/grid/GridViewHead.vue index a883feeae..0f37a8802 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewHead.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewHead.vue @@ -1,8 +1,13 @@ <template> <div class="grid-view__head"> + <div + v-for="groupBy in includeGroupBy ? view.group_bys : []" + :key="'field-group-' + groupBy.field" + class="grid-view__head-group" + ></div> <div v-if="includeRowDetails" - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" :style="{ width: gridViewRowDetailsWidth + 'px' }" > <GridViewRowIdentifierDropdown @@ -19,7 +24,7 @@ ></GridViewRowIdentifierDropdown> </div> <GridViewFieldType - v-for="field in fields" + v-for="field in visibleFields" :key="'field-type-' + field.id" :database="database" :table="table" @@ -80,7 +85,7 @@ export default { }, mixins: [gridViewHelpers], props: { - fields: { + visibleFields: { type: Array, required: true, }, @@ -106,6 +111,11 @@ export default { required: false, default: () => false, }, + includeGroupBy: { + type: Boolean, + required: false, + default: () => false, + }, includeAddField: { type: Boolean, required: false, diff --git a/web-frontend/modules/database/components/view/grid/GridViewPlaceholder.vue b/web-frontend/modules/database/components/view/grid/GridViewPlaceholder.vue index 7e1d28d4b..ac323e2bb 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewPlaceholder.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewPlaceholder.vue @@ -7,11 +7,18 @@ }" > <div - v-for="(value, index) in placeholderPositions" - :key="'placeholder-column-' + index" - class="grid-view__placeholder-column" - :style="{ left: value - 1 + 'px' }" + v-if="includeGroupBy" + class="grid-view__placeholder-groups" + :style="{ width: groupByWidth + 'px' }" ></div> + <template v-if="includeRowDetails || visibleFields.length > 0"> + <div + v-for="(value, index) in placeholderPositions" + :key="'placeholder-column-' + index" + class="grid-view__placeholder-column" + :style="{ left: value - 1 + 'px' }" + ></div> + </template> </div> </template> @@ -24,34 +31,50 @@ export default { name: 'GridViewPlaceholder', mixins: [gridViewHelpers], props: { - fields: { + visibleFields: { type: Array, required: true, }, + view: { + type: Object, + required: true, + }, includeRowDetails: { type: Boolean, required: true, }, + includeGroupBy: { + type: Boolean, + required: false, + default: () => false, + }, }, computed: { + groupByWidth() { + return this.includeGroupBy + ? this.activeGroupBys.length * this.groupWidth + : 0 + }, /** * Calculate the left positions of the placeholder columns. These are the gray * vertical lines that are always visible, even when the data hasn't loaded yet. */ placeholderPositions() { - let last = 0 + let last = this.groupByWidth + last += this.includeRowDetails ? this.gridViewRowDetailsWidth : 0 const placeholderPositions = {} - this.fields.forEach((field) => { + this.visibleFields.forEach((field) => { last += this.getFieldWidth(field.id) placeholderPositions[field.id] = last }) return placeholderPositions }, placeholderWidth() { - let width = this.fields.reduce( + let width = this.visibleFields.reduce( (value, field) => this.getFieldWidth(field.id) + value, 0 ) + width += this.groupByWidth if (this.includeRowDetails) { width += this.gridViewRowDetailsWidth } @@ -65,6 +88,8 @@ export default { placeholderHeight: this.$options.propsData.storePrefix + 'view/grid/getPlaceholderHeight', + activeGroupBys: + this.$options.propsData.storePrefix + 'view/grid/getActiveGroupBys', }), } }, diff --git a/web-frontend/modules/database/components/view/grid/GridViewRow.vue b/web-frontend/modules/database/components/view/grid/GridViewRow.vue index a7d6cd8d1..9e2830764 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRow.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRow.vue @@ -20,6 +20,18 @@ @mouseleave="$emit('row-hover', { row, value: false })" @contextmenu.prevent="$emit('row-context', { row, event: $event })" > + <template v-if="includeGroupBy"> + <GridViewRowGroup + v-for="group in groups" + :key="'group-' + group.id" + :width="groupWidth" + :start="group.start" + :end="group.end" + :group-by="group.groupBy" + :all-fields-in-table="allFieldsInTable" + :row="row" + ></GridViewRowGroup> + </template> <template v-if="includeRowDetails"> <div v-if=" @@ -38,7 +50,8 @@ }}</template> </div> <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" + :class="{ 'grid-view__column--group-end': groupEnd }" :style="{ width: gridViewRowDetailsWidth + 'px' }" > <div @@ -93,6 +106,7 @@ :multi-select-position="getMultiSelectPosition(row.id, field)" :read-only="readOnly" :store-prefix="storePrefix" + :group-end="groupEnd" :style="{ width: fieldWidths[field.id] + 'px', ...getSelectedCellStyle(field), @@ -122,10 +136,17 @@ import GridViewCell from '@baserow/modules/database/components/view/grid/GridVie import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers' import GridViewRowExpandButton from '@baserow/modules/database/components/view/grid/GridViewRowExpandButton' import RecursiveWrapper from '@baserow/modules/database/components/RecursiveWrapper' +import GridViewRowGroup from '@baserow/modules/database/components/view/grid/GridViewRowGroup.vue' +import groupBy from '@baserow/modules/database/services/groupBy' export default { name: 'GridViewRow', - components: { GridViewRowExpandButton, GridViewCell, RecursiveWrapper }, + components: { + GridViewRowGroup, + GridViewRowExpandButton, + GridViewCell, + RecursiveWrapper, + }, mixins: [gridViewHelpers], provide() { return { @@ -145,7 +166,11 @@ export default { type: Object, required: true, }, - fields: { + groups: { + type: Array, + required: true, + }, + renderedFields: { type: Array, required: true, }, @@ -153,7 +178,11 @@ export default { type: Object, required: true, }, - allFields: { + visibleFields: { + type: Array, + required: true, + }, + allFieldsInTable: { type: Array, required: true, }, @@ -166,6 +195,11 @@ export default { required: false, default: () => false, }, + includeGroupBy: { + type: Boolean, + required: false, + default: () => false, + }, readOnly: { type: Boolean, required: true, @@ -183,7 +217,7 @@ export default { type: Number, required: true, }, - canFitInTwoColumns: { + primaryFieldIsSticky: { type: Boolean, required: false, default: () => true, @@ -207,8 +241,8 @@ export default { }, computed: { /** - * This component already accepts a `fields` property containing the fields that - * must be rendered based on the viewport width and horizontal scroll offset, + * This component already accepts a `renderedFields` property containing the fields + * that must be rendered based on the viewport width and horizontal scroll offset, * meaning it only renders the fields that are in the viewport. Because a selected * field must always be rendered, this computed property checks if there is a * selected field and if so, it's added to the array. This doesn't influence the @@ -220,12 +254,12 @@ export default { // If the row doesn't have a selected field, we can safely return the fields // because we just want to render the fields inside of the view port. if (!this.row._.selected) { - return this.fields + return this.renderedFields } // Check if the selected field exists in the all fields array, so not just the to // be rendered ones. - const selectedField = this.allFields.find( + const selectedField = this.visibleFields.find( (field) => field.id === this.row._.selectedFieldId ) @@ -233,15 +267,16 @@ export default { // add it because it's already rendered. if ( selectedField === undefined || - this.fields.find((field) => field.id === selectedField.id) !== undefined + this.renderedFields.find((field) => field.id === selectedField.id) !== + undefined ) { - return this.fields + return this.renderedFields } // If the selected field exists in all fields, but not in fields it must be added // to the fields array because we want to render it. It won't influence the other // cells because it's positioned absolute. - const fields = this.fields.slice() + const fields = this.renderedFields.slice() fields.unshift(selectedField) return fields }, @@ -259,8 +294,12 @@ export default { return this.row.id } }, + groupEnd() { + return this.groups.length > 0 && this.groups[this.groups.length - 1].end + }, }, methods: { + groupBy, isCellSelected(fieldId) { return this.row._.selected && this.row._.selectedFieldId === fieldId }, @@ -285,9 +324,9 @@ export default { rowId ) - const allFieldIds = this.allFields.map((field) => field.id) + const allFieldIds = this.visibleFields.map((field) => field.id) let fieldIndex = allFieldIds.findIndex((id) => field.id === id) - fieldIndex += !field.primary && this.canFitInTwoColumns ? 1 : 0 + fieldIndex += !field.primary && this.primaryFieldIsSticky ? 1 : 0 const [minRow, maxRow] = this.$store.getters[ @@ -346,7 +385,8 @@ export default { * otherwise certain functionality won't work. */ getSelectedCellStyle(field) { - const exists = this.fields.find((f) => f.id === field.id) !== undefined + const exists = + this.renderedFields.find((f) => f.id === field.id) !== undefined // If the field already exists in the field list it means that it's already // rendered. In that case we don't have to provide any other styling because it's @@ -360,14 +400,15 @@ export default { // the other cells. const styling = { position: 'absolute' } - const selectedFieldIndex = this.allFields.findIndex( + const selectedFieldIndex = this.visibleFields.findIndex( (field) => field.id === this.row._.selectedFieldId ) - const firstVisibleFieldIndex = this.allFields.findIndex( - (field) => field.id === this.fields[0].id + const firstVisibleFieldIndex = this.visibleFields.findIndex( + (field) => field.id === this.renderedFields[0].id ) - const lastVisibleFieldIndex = this.allFields.findIndex( - (field) => field.id === this.fields[this.fields.length - 1].id + const lastVisibleFieldIndex = this.visibleFields.findIndex( + (field) => + field.id === this.renderedFields[this.renderedFields.length - 1].id ) // Positions the selected field cell on the right position without influencing the @@ -378,14 +419,14 @@ export default { // If the selected field must be positioned before the other fields let spaceBetween = 0 for (let i = selectedFieldIndex; i < firstVisibleFieldIndex; i++) { - spaceBetween += this.fieldWidths[this.allFields[i].id] + spaceBetween += this.fieldWidths[this.visibleFields[i].id] } styling.left = -spaceBetween + 'px' } else if (selectedFieldIndex > lastVisibleFieldIndex) { // If the selected field must be positioned after the other fields. let spaceBetween = 0 for (let i = lastVisibleFieldIndex; i < selectedFieldIndex; i++) { - spaceBetween += this.fieldWidths[this.allFields[i].id] + spaceBetween += this.fieldWidths[this.visibleFields[i].id] } styling.right = -spaceBetween + 'px' } diff --git a/web-frontend/modules/database/components/view/grid/GridViewRowAdd.vue b/web-frontend/modules/database/components/view/grid/GridViewRowAdd.vue index 346f2be40..f3ec8e4ce 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRowAdd.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRowAdd.vue @@ -27,7 +27,7 @@ export default { name: 'GridViewRowAdd', mixins: [gridViewHelpers], props: { - fields: { + visibleFields: { type: Array, required: true, }, @@ -38,7 +38,7 @@ export default { }, computed: { width() { - let width = this.fields.reduce( + let width = this.visibleFields.reduce( (value, field) => this.getFieldWidth(field.id) + value, 0 ) diff --git a/web-frontend/modules/database/components/view/grid/GridViewRowDragging.vue b/web-frontend/modules/database/components/view/grid/GridViewRowDragging.vue index 6db2f6bd7..fcc4cafd1 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRowDragging.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRowDragging.vue @@ -1,5 +1,9 @@ <template> - <div v-show="dragging" class="grid-view__row-dragging-container"> + <div + v-show="dragging" + class="grid-view__row-dragging-container" + :style="{ left: offset + 'px' }" + > <div class="grid-view__row-dragging" :style="{ width: width + 'px', top: draggingTop + 'px' }" @@ -37,6 +41,11 @@ export default { type: String, required: true, }, + offset: { + type: Number, + required: false, + default: () => 0, + }, }, data() { return { diff --git a/web-frontend/modules/database/components/view/grid/GridViewRowGroup.vue b/web-frontend/modules/database/components/view/grid/GridViewRowGroup.vue new file mode 100644 index 000000000..3e91919b1 --- /dev/null +++ b/web-frontend/modules/database/components/view/grid/GridViewRowGroup.vue @@ -0,0 +1,73 @@ +<template functional> + <div + class="grid-view__group" + :style="{ width: props.width + 'px' }" + :class="{ + 'grid-view__group--end': props.end, + }" + > + <div + v-if="props.start" + class="grid-view__group-cell" + :set=" + (field = $options.methods.getField( + props.allFieldsInTable, + props.groupBy + )) + " + > + <div class="grid-view__group-name"> + {{ field.name }} + </div> + <div class="grid-view__group-value"> + <component + :is="$options.methods.getCardComponent(field, parent)" + :field="field" + :value="props.row['field_' + field.id]" + /> + </div> + </div> + </div> +</template> + +<script> +export default { + name: 'GridViewRowGroup', + props: { + width: { + type: Number, + required: true, + }, + start: { + type: Boolean, + required: true, + }, + end: { + type: Boolean, + required: true, + }, + groupBy: { + type: Object, + required: true, + }, + allFieldsInTable: { + type: Array, + required: true, + }, + row: { + type: Object, + required: true, + }, + }, + methods: { + getField(allFieldsInTable, groupBy) { + const field = allFieldsInTable.find((f) => f.id === groupBy.field) + return field + }, + getCardComponent(field, parent) { + const fieldType = parent.$registry.get('field', field.type) + return fieldType.getGroupByComponent(field) + }, + }, +} +</script> diff --git a/web-frontend/modules/database/components/view/grid/GridViewRows.vue b/web-frontend/modules/database/components/view/grid/GridViewRows.vue index b7c00eb06..98bbfffe4 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewRows.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewRows.vue @@ -2,23 +2,26 @@ <div class="grid-view__rows" :style="{ - transform: `translateY(${rowsTop}px) translateX(${leftOffset}px)`, + transform: `translateY(${rowsTop}px) translateX(${leftOffset || 0}px)`, }" > <GridViewRow v-for="(row, index) in rows" :key="`row-${row._.persistentId}`" + :groups="groupsPerRow[row.id]" :view="view" :workspace-id="workspaceId" :row="row" - :fields="fields" - :all-fields="allFields" - :can-fit-in-two-columns="canFitInTwoColumns" + :rendered-fields="renderedFields" + :visible-fields="visibleFields" + :all-fields-in-table="allFieldsInTable" + :primary-field-is-sticky="primaryFieldIsSticky" :field-widths="fieldWidths" :include-row-details="includeRowDetails" + :include-group-by="includeGroupBy" :decorations-by-place="decorationsByPlace" :read-only="readOnly" - :can-drag="view.sortings.length === 0" + :can-drag="view.sortings.length === 0 && activeGroupBys.length === 0" :store-prefix="storePrefix" :row-identifier-type="view.row_identifier_type" :count="index + rowsStartIndex + bufferStartIndex + 1" @@ -38,11 +41,26 @@ export default { components: { GridViewRow }, mixins: [gridViewHelpers], props: { - fields: { + /** + * The visible fields that are within the viewport. The other ones are not rendered + * for performance reasons. + */ + renderedFields: { type: Array, required: true, }, - allFields: { + /** + * The fields that are chosen to be visible within the view. + */ + visibleFields: { + type: Array, + required: true, + }, + /** + * All the fields in the table, regardless of the visibility, or whether they + * should be rendered. + */ + allFieldsInTable: { type: Array, required: true, }, @@ -64,6 +82,11 @@ export default { required: false, default: () => false, }, + includeGroupBy: { + type: Boolean, + required: false, + default: () => false, + }, readOnly: { type: Boolean, required: true, @@ -72,7 +95,7 @@ export default { type: Number, required: true, }, - canFitInTwoColumns: { + primaryFieldIsSticky: { type: Boolean, required: false, default: () => true, @@ -81,22 +104,113 @@ export default { computed: { fieldWidths() { const fieldWidths = {} - this.allFields.forEach((field) => { + this.visibleFields.forEach((field) => { fieldWidths[field.id] = this.getFieldWidth(field.id) }) return fieldWidths }, + /** + * This computed property prepares an array with clear instructions on the groups + * for each row. It will hold data whether the row is the start of a group, or the + * end, and for which group that is. + * + * We're calculating this for all the rows in the buffer because that way we can + * immediately render the correct borders of the rows, even if the user is + * scrolling fast through them. + * + * It returns a structure like: + * + * { + * [rowId]: [ + * { + * id: groupById, + * start: false, + * end: true, + * groupBy: {} + * } + * ] + * } + */ + groupsPerRow() { + const groupBys = this.activeGroupBys + const rows = this.allRows + const groups = {} + const fieldTypeMap = {} + const fieldMap = {} + + rows.forEach((row, index) => { + const previousRow = rows[index - 1] + const nextRow = rows[index + 1] + + let lastGroupStart = false + let lastGroupEnd = false + + const startEndPerGroup = groupBys.map((groupBy) => { + let start = false // The start of a group based on the group by value. + let end = false // The end of a group based on the group by value. + let fieldType = fieldTypeMap[groupBy.field] + let field = fieldMap[groupBy.field] + + if (fieldType === undefined) { + field = this.allFieldsInTable.find((f) => f.id === groupBy.field) + fieldType = this.$registry.get('field', field.type) + fieldMap[groupBy.field] = field + fieldTypeMap[groupBy.field] = fieldType + } + + if ( + previousRow === undefined || + !fieldType.isEqual( + field, + previousRow[`field_${groupBy.field}`], + row[`field_${groupBy.field}`] + ) || + lastGroupStart + ) { + start = true + } + + if ( + nextRow === undefined || + !fieldType.isEqual( + field, + nextRow[`field_${groupBy.field}`], + row[`field_${groupBy.field}`] + ) || + lastGroupEnd + ) { + end = true + } + + lastGroupStart = start + lastGroupEnd = end + + return { + id: groupBy.id, + start, + end, + groupBy, + } + }) + + groups[row.id] = startEndPerGroup + }) + return groups + }, }, beforeCreate() { this.$options.computed = { ...(this.$options.computed || {}), ...mapGetters({ rows: this.$options.propsData.storePrefix + 'view/grid/getRows', + allRows: this.$options.propsData.storePrefix + 'view/grid/getAllRows', rowsTop: this.$options.propsData.storePrefix + 'view/grid/getRowsTop', rowsStartIndex: this.$options.propsData.storePrefix + 'view/grid/getRowsStartIndex', bufferStartIndex: this.$options.propsData.storePrefix + 'view/grid/getBufferStartIndex', + activeGroupBys: + this.$options.propsData.storePrefix + 'view/grid/getActiveGroupBys', }), } }, diff --git a/web-frontend/modules/database/components/view/grid/GridViewSection.vue b/web-frontend/modules/database/components/view/grid/GridViewSection.vue index 3bbfae609..a5e677eb9 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewSection.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewSection.vue @@ -11,12 +11,18 @@ }, }" > + <div + v-for="(left, index) in groupByDividers" + :key="'group-by-divider-' + index" + class="grid-view__group-by-divider" + :style="{ left: left + 'px' }" + ></div> <div class="grid-view__inner" :style="{ 'min-width': width + 'px' }"> <GridViewHead :database="database" :table="table" :view="view" - :fields="fields" + :visible-fields="visibleFields" :include-field-width-handles="includeFieldWidthHandles" :include-row-details="includeRowDetails" :include-add-field="includeAddField" @@ -48,20 +54,24 @@ > <div class="grid-view__body-inner"> <GridViewPlaceholder - :fields="fields" + :visible-fields="visibleFields" + :view="view" :include-row-details="includeRowDetails" + :include-group-by="includeGroupBy" :store-prefix="storePrefix" ></GridViewPlaceholder> <GridViewRows ref="rows" :view="view" - :fields="fieldsToRender" + :rendered-fields="fieldsToRender" + :visible-fields="visibleFields" + :all-fields-in-table="allFieldsInTable" :workspace-id="database.workspace.id" - :all-fields="fields" :decorations-by-place="decorationsByPlace" :left-offset="fieldsLeftOffset" - :can-fit-in-two-columns="canFitInTwoColumns" + :primary-field-is-sticky="primaryFieldIsSticky" :include-row-details="includeRowDetails" + :include-group-by="includeGroupBy" :read-only="readOnly" :store-prefix="storePrefix" v-on="$listeners" @@ -69,24 +79,28 @@ <GridViewRowAdd v-if=" !readOnly && + (includeRowDetails || visibleFields.length > 0) && $hasPermission( 'database.table.create_row', table, database.workspace.id ) " - :fields="fields" + :visible-fields="visibleFields" :include-row-details="includeRowDetails" :store-prefix="storePrefix" v-on="$listeners" ></GridViewRowAdd> + <div v-else class="grid-view__row-placeholder"></div> </div> </div> <div class="grid-view__foot"> - <slot name="foot"></slot> + <div v-if="includeRowDetails" class="grid-view__foot-info"> + {{ $tc('gridView.rowCount', count, { count }) }} + </div> <template v-if="!publicGrid"> <div - v-for="field in fields" + v-for="field in visibleFields" :key="field.id" :style="{ width: getFieldWidth(field.id) + 'px' }" > @@ -144,7 +158,11 @@ export default { } }, props: { - fields: { + visibleFields: { + type: Array, + required: true, + }, + allFieldsInTable: { type: Array, required: true, }, @@ -174,6 +192,11 @@ export default { required: false, default: () => false, }, + includeGroupBy: { + type: Boolean, + required: false, + default: () => false, + }, includeAddField: { type: Boolean, required: false, @@ -189,7 +212,7 @@ export default { required: false, default: () => false, }, - canFitInTwoColumns: { + primaryFieldIsSticky: { type: Boolean, required: false, default: () => true, @@ -203,7 +226,7 @@ export default { return { // Render the first 20 fields by default so that there's at least some data when // the page is server side rendered. - fieldsToRender: this.fields.slice(0, 20), + fieldsToRender: this.visibleFields.slice(0, 20), // Indicates the offset fieldsLeftOffset: 0, } @@ -214,7 +237,7 @@ export default { * given options. */ width() { - let width = Object.values(this.fields).reduce( + let width = Object.values(this.visibleFields).reduce( (value, field) => this.getFieldWidth(field.id) + value, 0 ) @@ -231,12 +254,32 @@ export default { return width }, draggingFields() { - return this.fields.filter((f) => !f.primary) + return this.visibleFields.filter((f) => !f.primary) }, draggingOffset() { - return this.fields + let offset = this.visibleFields .filter((f) => f.primary) .reduce((sum, f) => sum + this.getFieldWidth(f.id), 0) + + if (this.includeRowDetails) { + offset += this.gridViewRowDetailsWidth + } + + return offset + }, + groupByDividers() { + if (!this.includeGroupBy) { + return [] + } + + let last = 0 + const dividers = this.activeGroupBys + .filter((groupBy, index) => index < this.activeGroupBys.length - 1) + .map(() => { + last += this.groupWidth + return last + }) + return dividers }, }, watch: { @@ -246,7 +289,7 @@ export default { this.updateVisibleFieldsInRow() }, }, - fields: { + visibleFields: { deep: true, handler() { this.updateVisibleFieldsInRow() @@ -260,6 +303,7 @@ export default { isMultiSelectHolding: this.$options.propsData.storePrefix + 'view/grid/isMultiSelectHolding', + count: this.$options.propsData.storePrefix + 'view/grid/getCount', }), } }, @@ -332,7 +376,7 @@ export default { // Create an array containing the fields that are currently visible in the // viewport and must be rendered. - const fieldsToRender = this.fields.filter((field) => { + const fieldsToRender = this.visibleFields.filter((field) => { const width = this.getFieldWidth(field.id) const right = left + width const visible = right >= viewportStart && left <= viewportEnd diff --git a/web-frontend/modules/database/components/view/grid/fields/GridViewFieldFile.vue b/web-frontend/modules/database/components/view/grid/fields/GridViewFieldFile.vue index 4cb7de7a7..1939a1f85 100644 --- a/web-frontend/modules/database/components/view/grid/fields/GridViewFieldFile.vue +++ b/web-frontend/modules/database/components/view/grid/fields/GridViewFieldFile.vue @@ -152,10 +152,10 @@ export default { const index = this.loadings.findIndex((l) => l.id === id) this.loadings.splice(index, 1) - - // Indicates that this component can be destroyed if it is not selected. - this.$emit('remove-keep-alive') } + + // Indicates that this component can be destroyed if it is not selected. + this.$emit('remove-keep-alive') }, select() { // While the field is selected we want to open the select row toast by pressing diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index 657e39b50..3b5d68557 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -215,6 +215,15 @@ export class FieldType extends Registerable { ) } + /** + * This component should represent the grouped by field's value in the grid view. To + * improve performance, this component should be a functional component. The card + * component almost always compatible here, so we're returning that one by default. + */ + getGroupByComponent() { + return this.getCardComponent() + } + /** * This component displays row change difference for values of the field type. */ @@ -256,6 +265,14 @@ export class FieldType extends Registerable { return [null, false].includes(value) } + /** + * Should return true if both provided values are equal. This is used to determine + * whether they both belong in the same group for example. + */ + isEqual(field, value1, value2) { + return JSON.stringify(value1) === JSON.stringify(value2) + } + /** * Should return a string containing the error if the value is invalid. If the * value is valid, then null must be returned. @@ -1693,6 +1710,21 @@ class BaseDateFieldType extends FieldType { getCanGroupByInView(field) { return true } + + isEqual(field, value1, value2) { + if (field.date_include_time) { + // Seconds are visually ignored for a field that includes the time, so we'd + // have to compare the values without them. A date is normally `null` or + // `2023-10-27T18:17:00.758266Z`, and if you only take the first 16 + // characters, it's the value without the seconds. + return ( + ('' + value1).substring(0, 16) === + ('' + value2).toString().substring(0, 16) + ) + } else { + return super.isEqual(field, value1, value2) + } + } } export class DateFieldType extends BaseDateFieldType { diff --git a/web-frontend/modules/database/mixins/gridViewHelpers.js b/web-frontend/modules/database/mixins/gridViewHelpers.js index f65f164f0..415590f83 100644 --- a/web-frontend/modules/database/mixins/gridViewHelpers.js +++ b/web-frontend/modules/database/mixins/gridViewHelpers.js @@ -10,6 +10,7 @@ export default { data() { return { gridViewRowDetailsWidth: 72, + groupWidth: 200, } }, beforeCreate() { @@ -19,6 +20,8 @@ export default { fieldOptions: this.$options.propsData.storePrefix + 'view/grid/getAllFieldOptions', publicGrid: 'page/view/public/getIsPublic', + activeGroupBys: + this.$options.propsData.storePrefix + 'view/grid/getActiveGroupBys', }), } }, diff --git a/web-frontend/modules/database/services/view/grid.js b/web-frontend/modules/database/services/view/grid.js index 1ae06a8e9..7945666f1 100644 --- a/web-frontend/modules/database/services/view/grid.js +++ b/web-frontend/modules/database/services/view/grid.js @@ -13,6 +13,7 @@ export default (client) => { searchMode = '', publicUrl = false, publicAuthToken = null, + groupBy = '', orderBy = '', filters = {}, includeFields = [], @@ -45,6 +46,10 @@ export default (client) => { } } + if (groupBy) { + params.append('group_by', groupBy) + } + if (orderBy) { params.append('order_by', orderBy) } diff --git a/web-frontend/modules/database/store/view/grid.js b/web-frontend/modules/database/store/view/grid.js index 1607dbb88..9dad59e53 100644 --- a/web-frontend/modules/database/store/view/grid.js +++ b/web-frontend/modules/database/store/view/grid.js @@ -13,6 +13,7 @@ import { getRowSortFunction, matchSearchFilters, getFilters, + getGroupBy, getOrderBy, } from '@baserow/modules/database/utils/view' import { RefreshCancelledError } from '@baserow/modules/core/errors' @@ -125,6 +126,7 @@ export const state = () => ({ // have any matching cells will still be displayed. hideRowsNotMatchingSearch: true, fieldAggregationData: {}, + activeGroupBys: [], }) export const mutations = { @@ -142,6 +144,9 @@ export const mutations = { state.activeSearchTerm = '' state.hideRowsNotMatchingSearch = true }, + SET_ACTIVE_GROUP_BYS(state, groupBys) { + state.activeGroupBys = groupBys + }, SET_SEARCH(state, { activeSearchTerm, hideRowsNotMatchingSearch }) { state.activeSearchTerm = activeSearchTerm.trim() state.hideRowsNotMatchingSearch = hideRowsNotMatchingSearch @@ -614,6 +619,7 @@ export const actions = { searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], + groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(rootGetters, getters.getLastGridId), filters: getFilters(rootGetters, getters.getLastGridId), }) @@ -775,6 +781,7 @@ export const actions = { searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], + groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(rootGetters, getters.getLastGridId), filters: getFilters(rootGetters, getters.getLastGridId), }) @@ -849,6 +856,7 @@ export const actions = { searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], + groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(rootGetters, getters.getLastGridId), filters: getFilters(rootGetters, getters.getLastGridId), }) @@ -898,6 +906,9 @@ export const actions = { }) return lastRefreshRequest }, + updateActiveGroupBys({ commit }, groupBys) { + commit('SET_ACTIVE_GROUP_BYS', groupBys) + }, /** * Updates the field options of a given field and also makes an API request to the * backend with the changed values. If the request fails the action is reverted. @@ -1525,6 +1536,7 @@ export const actions = { searchMode: getDefaultSearchModeFromEnv(this.$config), publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], + groupBy: getGroupBy(rootGetters, getters.getLastGridId), orderBy: getOrderBy(rootGetters, getters.getLastGridId), filters: getFilters(rootGetters, getters.getLastGridId), includeFields: fields, @@ -2902,6 +2914,9 @@ export const getters = { return row._.selected && row._.selectedFieldId !== -1 }) }, + getActiveGroupBys(state) { + return state.activeGroupBys + }, } export default { diff --git a/web-frontend/modules/database/utils/view.js b/web-frontend/modules/database/utils/view.js index 2c526ec6b..2a2f1e2de 100644 --- a/web-frontend/modules/database/utils/view.js +++ b/web-frontend/modules/database/utils/view.js @@ -425,6 +425,19 @@ export function newFieldMatchesActiveSearchTerm( return false } +export function getGroupBy(rootGetters, viewId) { + if (rootGetters['page/view/public/getIsPublic']) { + const view = rootGetters['view/get'](viewId) + return view.group_bys + .map((groupBy) => { + return `${groupBy.order === 'DESC' ? '-' : ''}field_${groupBy.field}` + }) + .join(',') + } else { + return '' + } +} + export function getOrderBy(rootGetters, viewId) { if (rootGetters['page/view/public/getIsPublic']) { const view = rootGetters['view/get'](viewId) diff --git a/web-frontend/modules/database/viewTypes.js b/web-frontend/modules/database/viewTypes.js index 877acdca7..b07ae89d0 100644 --- a/web-frontend/modules/database/viewTypes.js +++ b/web-frontend/modules/database/viewTypes.js @@ -8,6 +8,7 @@ import FormView from '@baserow/modules/database/components/view/form/FormView' import FormViewHeader from '@baserow/modules/database/components/view/form/FormViewHeader' import { FileFieldType } from '@baserow/modules/database/fieldTypes' import { newFieldMatchesActiveSearchTerm } from '@baserow/modules/database/utils/view' +import { clone } from '@baserow/modules/core/utils/object' export const maxPossibleOrderValue = 32767 @@ -366,6 +367,13 @@ export class GridViewType extends ViewType { gridId: view.id, fields, }) + // The grid view store keeps a copy of the group bys that must only be updated + // after the refresh of the page. This is because the group by depends on the rows + // being sorted, and this will only be the case after a refresh. + await store.dispatch( + storePrefix + 'view/grid/updateActiveGroupBys', + clone(view.group_bys) + ) } async refresh( diff --git a/web-frontend/test/fixtures/mockServer.js b/web-frontend/test/fixtures/mockServer.js index f35b65751..d9cce1d0d 100644 --- a/web-frontend/test/fixtures/mockServer.js +++ b/web-frontend/test/fixtures/mockServer.js @@ -62,11 +62,12 @@ export class MockServer { createGridView( application, table, - { filters = [], sortings = [], decorations = [], ...rest } + { filters = [], sortings = [], groupBys = [], decorations = [], ...rest } ) { return createGridView(this.mock, application, table, { filters, sortings, + groupBys, decorations, ...rest, }) diff --git a/web-frontend/test/fixtures/view.js b/web-frontend/test/fixtures/view.js index d4cb8f7cd..51ca44f81 100644 --- a/web-frontend/test/fixtures/view.js +++ b/web-frontend/test/fixtures/view.js @@ -51,6 +51,7 @@ export function createGridView( viewId = 1, filters = [], sortings = [], + groupBys = [], decorations = [], publicView = false, } @@ -74,6 +75,7 @@ export function createGridView( row_identifier_type: 'id', filters, sortings, + group_bys: groupBys, decorations, } mock.onGet(`/database/views/table/${tableId}/`).reply(200, [gridView]) diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap index 2fbb9e2ff..35f58842e 100644 --- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap +++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap @@ -226,6 +226,7 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` class="grid-view__left" style="width: 72px;" > + <div class="grid-view__inner" style="min-width: 72px;" @@ -233,8 +234,9 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` <div class="grid-view__head" > + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <!----> @@ -252,19 +254,26 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` <div class="grid-view__placeholder" style="height: 33px; width: 72px;" - /> + > + <div + class="grid-view__placeholder-groups" + style="width: 0px;" + /> + + </div> <div class="grid-view__rows" - style="transform: translateY(0px) translateX(nullpx);" + style="transform: translateY(0px) translateX(0px);" > <div class="grid-view__row" > + <!----> <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -295,7 +304,9 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` </div> </div> - <!----> + <div + class="grid-view__row-placeholder" + /> </div> </div> @@ -341,6 +352,7 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` class="grid-view__right" style="left: 72px;" > + <div class="grid-view__inner" style="min-width: 1000px;" @@ -348,6 +360,7 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` <div class="grid-view__head" > + <!----> <div @@ -496,6 +509,8 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` class="grid-view__placeholder" style="height: 33px; width: 800px;" > + <!----> + <div class="grid-view__placeholder-column" style="left: 799px;" @@ -523,6 +538,8 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` > <!----> + <!----> + <div class="grid-view__column" style="width: 200px;" @@ -554,13 +571,16 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` </div> </div> - <!----> + <div + class="grid-view__row-placeholder" + /> </div> </div> <div class="grid-view__foot" > + <!----> <!----> </div> @@ -583,7 +603,7 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = ` <div class="grid-view__row-dragging-container" - style="display: none;" + style="left: 0px; display: none;" > <div class="grid-view__row-dragging" diff --git a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap index 3a3a50344..a90f315e2 100644 --- a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap @@ -17,6 +17,7 @@ exports[`GridView component with decoration Default component with first_cell de class="grid-view__left" style="width: 72px;" > + <div class="grid-view__inner" style="min-width: 72px;" @@ -24,8 +25,9 @@ exports[`GridView component with decoration Default component with first_cell de <div class="grid-view__head" > + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -50,19 +52,26 @@ exports[`GridView component with decoration Default component with first_cell de <div class="grid-view__placeholder" style="height: 66px; width: 72px;" - /> + > + <div + class="grid-view__placeholder-groups" + style="width: 0px;" + /> + + </div> <div class="grid-view__rows" - style="transform: translateY(0px) translateX(nullpx);" + style="transform: translateY(0px) translateX(0px);" > <div class="grid-view__row" > + <!----> <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -162,6 +171,7 @@ exports[`GridView component with decoration Default component with first_cell de class="grid-view__right" style="left: 72px;" > + <div class="grid-view__inner" style="min-width: 800px;" @@ -169,6 +179,7 @@ exports[`GridView component with decoration Default component with first_cell de <div class="grid-view__head" > + <!----> <div @@ -311,6 +322,8 @@ exports[`GridView component with decoration Default component with first_cell de class="grid-view__placeholder" style="height: 66px; width: 600px;" > + <!----> + <div class="grid-view__placeholder-column" style="left: 199px;" @@ -334,6 +347,8 @@ exports[`GridView component with decoration Default component with first_cell de > <!----> + <!----> + <div class="grid-view__column" style="width: 200px;" @@ -386,6 +401,7 @@ exports[`GridView component with decoration Default component with first_cell de <div class="grid-view__foot" > + <!----> <div style="width: 200px;" @@ -437,7 +453,7 @@ exports[`GridView component with decoration Default component with first_cell de <div class="grid-view__row-dragging-container" - style="display: none;" + style="left: 0px; display: none;" > <div class="grid-view__row-dragging" @@ -471,6 +487,7 @@ exports[`GridView component with decoration Default component with row wrapper d class="grid-view__left" style="width: 72px;" > + <div class="grid-view__inner" style="min-width: 72px;" @@ -478,8 +495,9 @@ exports[`GridView component with decoration Default component with row wrapper d <div class="grid-view__head" > + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -504,11 +522,17 @@ exports[`GridView component with decoration Default component with row wrapper d <div class="grid-view__placeholder" style="height: 66px; width: 72px;" - /> + > + <div + class="grid-view__placeholder-groups" + style="width: 0px;" + /> + + </div> <div class="grid-view__rows" - style="transform: translateY(0px) translateX(nullpx);" + style="transform: translateY(0px) translateX(0px);" > <div class="test-wrapper" @@ -576,6 +600,7 @@ exports[`GridView component with decoration Default component with row wrapper d class="grid-view__right" style="left: 72px;" > + <div class="grid-view__inner" style="min-width: 800px;" @@ -583,6 +608,7 @@ exports[`GridView component with decoration Default component with row wrapper d <div class="grid-view__head" > + <!----> <div @@ -725,6 +751,8 @@ exports[`GridView component with decoration Default component with row wrapper d class="grid-view__placeholder" style="height: 66px; width: 600px;" > + <!----> + <div class="grid-view__placeholder-column" style="left: 199px;" @@ -769,6 +797,7 @@ exports[`GridView component with decoration Default component with row wrapper d <div class="grid-view__foot" > + <!----> <div style="width: 200px;" @@ -820,7 +849,7 @@ exports[`GridView component with decoration Default component with row wrapper d <div class="grid-view__row-dragging-container" - style="display: none;" + style="left: 0px; display: none;" > <div class="grid-view__row-dragging" @@ -854,6 +883,7 @@ exports[`GridView component with decoration Default component with unavailable d class="grid-view__left" style="width: 72px;" > + <div class="grid-view__inner" style="min-width: 72px;" @@ -861,8 +891,9 @@ exports[`GridView component with decoration Default component with unavailable d <div class="grid-view__head" > + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -887,19 +918,26 @@ exports[`GridView component with decoration Default component with unavailable d <div class="grid-view__placeholder" style="height: 66px; width: 72px;" - /> + > + <div + class="grid-view__placeholder-groups" + style="width: 0px;" + /> + + </div> <div class="grid-view__rows" - style="transform: translateY(0px) translateX(nullpx);" + style="transform: translateY(0px) translateX(0px);" > <div class="grid-view__row" > + <!----> <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -993,6 +1031,7 @@ exports[`GridView component with decoration Default component with unavailable d class="grid-view__right" style="left: 72px;" > + <div class="grid-view__inner" style="min-width: 800px;" @@ -1000,6 +1039,7 @@ exports[`GridView component with decoration Default component with unavailable d <div class="grid-view__head" > + <!----> <div @@ -1142,6 +1182,8 @@ exports[`GridView component with decoration Default component with unavailable d class="grid-view__placeholder" style="height: 66px; width: 600px;" > + <!----> + <div class="grid-view__placeholder-column" style="left: 199px;" @@ -1165,6 +1207,8 @@ exports[`GridView component with decoration Default component with unavailable d > <!----> + <!----> + <div class="grid-view__column" style="width: 200px;" @@ -1217,6 +1261,7 @@ exports[`GridView component with decoration Default component with unavailable d <div class="grid-view__foot" > + <!----> <div style="width: 200px;" @@ -1268,7 +1313,7 @@ exports[`GridView component with decoration Default component with unavailable d <div class="grid-view__row-dragging-container" - style="display: none;" + style="left: 0px; display: none;" > <div class="grid-view__row-dragging" diff --git a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewRows.spec.js.snap b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewRows.spec.js.snap index f884dc7a2..d898a9a2e 100644 --- a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewRows.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewRows.spec.js.snap @@ -10,6 +10,8 @@ exports[`GridViewRows component Default component 1`] = ` > <!----> + <!----> + <div class="grid-view__column" style="width: 200px;" @@ -44,6 +46,8 @@ exports[`GridViewRows component Default component 1`] = ` > <!----> + <!----> + <div class="grid-view__column" style="width: 200px;" @@ -78,6 +82,8 @@ exports[`GridViewRows component Default component 1`] = ` > <!----> + <!----> + <div class="grid-view__column" style="width: 200px;" @@ -120,8 +126,10 @@ exports[`GridViewRows component With row details 1`] = ` > <!----> + <!----> + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -185,8 +193,10 @@ exports[`GridViewRows component With row details 1`] = ` > <!----> + <!----> + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div @@ -250,8 +260,10 @@ exports[`GridViewRows component With row details 1`] = ` > <!----> + <!----> + <div - class="grid-view__column" + class="grid-view__column grid-view__column--no-border-right" style="width: 72px;" > <div diff --git a/web-frontend/test/unit/database/components/view/grid/gridViewRows.spec.js b/web-frontend/test/unit/database/components/view/grid/gridViewRows.spec.js index fdb97d23e..610762245 100644 --- a/web-frontend/test/unit/database/components/view/grid/gridViewRows.spec.js +++ b/web-frontend/test/unit/database/components/view/grid/gridViewRows.spec.js @@ -82,8 +82,9 @@ describe('GridViewRows component', () => { const wrapper1 = await mountComponent({ view, - fields, - allFields: fields, + renderedFields: fields, + visibleFields: fields, + allFieldsInTable: fields, leftOffset: 0, readOnly: false, includeRowDetails: false, @@ -100,8 +101,9 @@ describe('GridViewRows component', () => { const wrapper1 = await mountComponent({ view, - fields, - allFields: fields, + renderedFields: fields, + visibleFields: fields, + allFieldsInTable: fields, leftOffset: 0, readOnly: false, includeRowDetails: true,