From 6ac5a3b17a9285449130688e186a4334db62d829 Mon Sep 17 00:00:00 2001 From: Bram Date: Sat, 16 May 2026 14:41:20 +0200 Subject: [PATCH] feat: Add sort to kanban view (#5341) --- .../feature/764_add_sort_to_kanban_view.json | 9 + .../baserow_premium/api/views/kanban/views.py | 13 + .../src/baserow_premium/views/handler.py | 10 +- .../api/views/views/test_kanban_views.py | 245 ++++++++++++++++++ .../views/test_premium_view_handler.py | 11 +- .../assets/scss/components/views/kanban.scss | 4 + .../views/kanban/KanbanViewStack.vue | 113 ++++++-- .../baserow_premium/services/views/kanban.js | 5 + .../baserow_premium/store/view/kanban.js | 56 +++- .../modules/baserow_premium/viewTypes.js | 17 +- premium/web-frontend/test/fixtures/kanban.js | 2 +- .../unit/premium/store/view/kanban.spec.js | 73 ++++++ .../view/kanban/kanbanViewStack.spec.js | 194 ++++++++++++++ 13 files changed, 714 insertions(+), 38 deletions(-) create mode 100644 changelog/entries/unreleased/feature/764_add_sort_to_kanban_view.json create mode 100644 premium/web-frontend/test/unit/premium/view/kanban/kanbanViewStack.spec.js diff --git a/changelog/entries/unreleased/feature/764_add_sort_to_kanban_view.json b/changelog/entries/unreleased/feature/764_add_sort_to_kanban_view.json new file mode 100644 index 0000000000..fb2c9cce38 --- /dev/null +++ b/changelog/entries/unreleased/feature/764_add_sort_to_kanban_view.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Add sort to kanban view", + "issue_origin": "github", + "issue_number": 764, + "domain": "database", + "bullet_points": [], + "created_at": "2026-05-10" +} diff --git a/premium/backend/src/baserow_premium/api/views/kanban/views.py b/premium/backend/src/baserow_premium/api/views/kanban/views.py index ccf174e2fe..8756baa520 100644 --- a/premium/backend/src/baserow_premium/api/views/kanban/views.py +++ b/premium/backend/src/baserow_premium/api/views/kanban/views.py @@ -10,11 +10,14 @@ from baserow.contrib.database.api.constants import ( ADHOC_FILTERS_API_PARAMS, ADHOC_FILTERS_API_PARAMS_NO_COMBINE, + ADHOC_SORTING_API_PARAM, LIMIT_LINKED_ITEMS_API_PARAM, ) from baserow.contrib.database.api.fields.errors import ( ERROR_FIELD_DOES_NOT_EXIST, ERROR_FILTER_FIELD_NOT_FOUND, + ERROR_ORDER_BY_FIELD_NOT_FOUND, + ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, ) from baserow.contrib.database.api.rows.serializers import ( RowSerializer, @@ -33,6 +36,8 @@ from baserow.contrib.database.fields.exceptions import ( FieldDoesNotExist, FilterFieldNotFound, + OrderByFieldNotFound, + OrderByFieldNotPossible, ) from baserow.contrib.database.rows.registries import row_metadata_registry from baserow.contrib.database.table.operations import ListRowsDatabaseTableOperationType @@ -303,6 +308,7 @@ class PublicKanbanViewView(APIView): ), ), *ADHOC_FILTERS_API_PARAMS, + ADHOC_SORTING_API_PARAM, LIMIT_LINKED_ITEMS_API_PARAM, ], tags=["Database table kanban view"], @@ -320,6 +326,8 @@ class PublicKanbanViewView(APIView): [ "ERROR_USER_NOT_IN_GROUP", "ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD", + "ERROR_ORDER_BY_FIELD_NOT_FOUND", + "ERROR_ORDER_BY_FIELD_NOT_POSSIBLE", "ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST", "ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD", "ERROR_FILTER_FIELD_NOT_FOUND", @@ -340,6 +348,8 @@ class PublicKanbanViewView(APIView): ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW ), FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND, + OrderByFieldNotFound: ERROR_ORDER_BY_FIELD_NOT_FOUND, + OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE, ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST, ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD, FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST, @@ -353,6 +363,7 @@ def get(self, request, slug: str, field_options: bool): """ adhoc_filters = AdHocFilters.from_request(request) + order_by = request.GET.get("order_by") view_handler = ViewHandler() view = view_handler.get_public_view_by_slug( @@ -384,6 +395,7 @@ def get(self, request, slug: str, field_options: bool): ) = ViewHandler().get_public_rows_queryset_and_field_ids( view, adhoc_filters=adhoc_filters, + order_by=order_by, table_model=model, view_type=view_type, ) @@ -407,6 +419,7 @@ def get(self, request, slug: str, field_options: bool): default_offset=default_offset, model=model, base_queryset=queryset, + apply_view_sorts=not order_by, ) for key, value in rows.items(): diff --git a/premium/backend/src/baserow_premium/views/handler.py b/premium/backend/src/baserow_premium/views/handler.py index a9ae25df66..8c8b949f07 100644 --- a/premium/backend/src/baserow_premium/views/handler.py +++ b/premium/backend/src/baserow_premium/views/handler.py @@ -38,6 +38,7 @@ def get_rows_grouped_by_single_select_field( adhoc_filters: Optional[AdHocFilters] = None, model: Optional[GeneratedTableModel] = None, base_queryset: Optional[QuerySet] = None, + apply_view_sorts: bool = True, ) -> Dict[str, Dict[str, Union[int, list]]]: """ This method fetches the rows grouped by a single select field in a query @@ -74,6 +75,10 @@ def get_rows_grouped_by_single_select_field( :param base_queryset: Optionally an alternative base queryset can be provided that will be used to fetch the rows. This should be provided if additional filters and/or sorts must be added. + :param apply_view_sorts: When `True` (the default) the view's own sorts are + applied to the queryset. Set to `False` when the caller has already + ordered the queryset (e.g. via an adhoc `order_by` query parameter) so + that the explicit ordering is preserved. :return: The fetched rows including the total count. """ @@ -86,7 +91,10 @@ def get_rows_grouped_by_single_select_field( model = table.get_model() if base_queryset is None: - base_queryset = model.objects.all().enhance_by_fields().order_by("order", "id") + base_queryset = model.objects.all().enhance_by_fields() + + if apply_view_sorts: + base_queryset = ViewHandler().apply_sorting(view, base_queryset) if adhoc_filters is None: adhoc_filters = AdHocFilters() diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py index 202b63d071..ac77ce2e3a 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py @@ -2531,3 +2531,248 @@ def test_reference_to_single_select_field_is_removed_after_trashing( json_response = response.json() assert len(json_response) == 0 + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_list_rows_applies_view_sortings_per_stack(api_client, premium_data_fixture): + user, token = premium_data_fixture.create_user_and_token( + has_active_premium_license=True + ) + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field(table=table, primary=True) + single_select_field = premium_data_fixture.create_single_select_field(table=table) + option_a = premium_data_fixture.create_select_option( + field=single_select_field, value="A", color="blue" + ) + kanban = premium_data_fixture.create_kanban_view( + table=table, single_select_field=single_select_field + ) + premium_data_fixture.create_view_sort(view=kanban, field=text_field, order="ASC") + + model = table.get_model() + # Insert rows in unsorted order; the view sort should reorder them. + row_a_c = model.objects.create( + **{ + f"field_{text_field.id}": "C", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_a_a = model.objects.create( + **{ + f"field_{text_field.id}": "A", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_a_b = model.objects.create( + **{ + f"field_{text_field.id}": "B", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_null_b = model.objects.create( + **{ + f"field_{text_field.id}": "B", + f"field_{single_select_field.id}_id": None, + } + ) + row_null_a = model.objects.create( + **{ + f"field_{text_field.id}": "A", + f"field_{single_select_field.id}_id": None, + } + ) + + url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id}) + response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"}) + response_json = response.json() + assert response.status_code == HTTP_200_OK + + null_results = response_json["rows"]["null"]["results"] + assert [r["id"] for r in null_results] == [row_null_a.id, row_null_b.id] + + a_results = response_json["rows"][str(option_a.id)]["results"] + assert [r["id"] for r in a_results] == [row_a_a.id, row_a_b.id, row_a_c.id] + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_list_public_rows_applies_view_sortings_per_stack( + api_client, premium_data_fixture +): + user, _ = premium_data_fixture.create_user_and_token() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field(table=table, primary=True) + single_select_field = premium_data_fixture.create_single_select_field(table=table) + option_a = premium_data_fixture.create_select_option( + field=single_select_field, value="A", color="blue" + ) + kanban_view = premium_data_fixture.create_kanban_view( + table=table, + user=user, + public=True, + single_select_field=single_select_field, + ) + premium_data_fixture.create_kanban_view_field_option( + kanban_view, text_field, hidden=False + ) + premium_data_fixture.create_view_sort( + view=kanban_view, field=text_field, order="DESC" + ) + + model = table.get_model() + row_a_a = model.objects.create( + **{ + f"field_{text_field.id}": "A", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_a_c = model.objects.create( + **{ + f"field_{text_field.id}": "C", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_a_b = model.objects.create( + **{ + f"field_{text_field.id}": "B", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + + response = api_client.get( + reverse( + "api:database:views:kanban:public_rows", + kwargs={"slug": kanban_view.slug}, + ) + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + + a_results = response_json["rows"][str(option_a.id)]["results"] + assert [r["id"] for r in a_results] == [row_a_c.id, row_a_b.id, row_a_a.id] + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_list_public_rows_adhoc_order_by_overrides_view_sortings( + api_client, premium_data_fixture +): + user, _ = premium_data_fixture.create_user_and_token() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field(table=table, primary=True) + single_select_field = premium_data_fixture.create_single_select_field(table=table) + option_a = premium_data_fixture.create_select_option( + field=single_select_field, value="A", color="blue" + ) + kanban_view = premium_data_fixture.create_kanban_view( + table=table, + user=user, + public=True, + single_select_field=single_select_field, + ) + premium_data_fixture.create_kanban_view_field_option( + kanban_view, text_field, hidden=False + ) + # The view's own sort is DESC; the adhoc `order_by` query parameter should + # override this and sort ASC instead. + premium_data_fixture.create_view_sort( + view=kanban_view, field=text_field, order="DESC" + ) + + model = table.get_model() + row_a_b = model.objects.create( + **{ + f"field_{text_field.id}": "B", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_a_a = model.objects.create( + **{ + f"field_{text_field.id}": "A", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + row_a_c = model.objects.create( + **{ + f"field_{text_field.id}": "C", + f"field_{single_select_field.id}_id": option_a.id, + } + ) + + response = api_client.get( + reverse( + "api:database:views:kanban:public_rows", + kwargs={"slug": kanban_view.slug}, + ) + + f"?order_by=field_{text_field.id}" + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + + a_results = response_json["rows"][str(option_a.id)]["results"] + assert [r["id"] for r in a_results] == [row_a_a.id, row_a_b.id, row_a_c.id] + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_list_public_rows_adhoc_order_by_invalid_field( + api_client, premium_data_fixture +): + user, _ = premium_data_fixture.create_user_and_token() + table = premium_data_fixture.create_database_table(user=user) + single_select_field = premium_data_fixture.create_single_select_field(table=table) + kanban_view = premium_data_fixture.create_kanban_view( + table=table, + user=user, + public=True, + single_select_field=single_select_field, + ) + + response = api_client.get( + reverse( + "api:database:views:kanban:public_rows", + kwargs={"slug": kanban_view.slug}, + ) + + "?order_by=field_999999" + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_list_public_rows_adhoc_order_by_hidden_field_not_found( + api_client, premium_data_fixture +): + user, _ = premium_data_fixture.create_user_and_token() + table = premium_data_fixture.create_database_table(user=user) + text_field = premium_data_fixture.create_text_field(table=table, primary=True) + hidden_text_field = premium_data_fixture.create_text_field(table=table) + single_select_field = premium_data_fixture.create_single_select_field(table=table) + kanban_view = premium_data_fixture.create_kanban_view( + table=table, + user=user, + public=True, + single_select_field=single_select_field, + ) + # Hide the secondary text field; sorting on it from the public endpoint + # should be rejected the same way as sorting on a non-existing field. + premium_data_fixture.create_kanban_view_field_option( + kanban_view, text_field, hidden=False + ) + premium_data_fixture.create_kanban_view_field_option( + kanban_view, hidden_text_field, hidden=True + ) + + response = api_client.get( + reverse( + "api:database:views:kanban:public_rows", + kwargs={"slug": kanban_view.slug}, + ) + + f"?order_by=field_{hidden_text_field.id}" + ) + response_json = response.json() + assert response.status_code == HTTP_400_BAD_REQUEST + assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND" diff --git a/premium/backend/tests/baserow_premium_tests/views/test_premium_view_handler.py b/premium/backend/tests/baserow_premium_tests/views/test_premium_view_handler.py index c3ebae3adc..39ae861d29 100644 --- a/premium/backend/tests/baserow_premium_tests/views/test_premium_view_handler.py +++ b/premium/backend/tests/baserow_premium_tests/views/test_premium_view_handler.py @@ -5,7 +5,6 @@ from baserow.contrib.database.views.models import ( OWNERSHIP_TYPE_COLLABORATIVE, GridView, - View, ) from baserow.core.exceptions import PermissionDenied from baserow_premium.views.handler import get_rows_grouped_by_single_select_field @@ -67,7 +66,7 @@ def test_get_rows_grouped_by_single_select_field( ) # The amount of queries including - with django_assert_num_queries(6): + with django_assert_num_queries(8): rows = get_rows_grouped_by_single_select_field( user, view, single_select_field, model=model ) @@ -174,9 +173,7 @@ def test_get_rows_grouped_by_single_select_field_not_existing_options_are_null( ): user = premium_data_fixture.create_user() table = premium_data_fixture.create_database_table(user=user) - view = View() - view.id = 999 # fake pk - view.table = table + view = premium_data_fixture.create_grid_view(table=table) text_field = premium_data_fixture.create_text_field(table=table, primary=True) single_select_field = premium_data_fixture.create_single_select_field(table=table) option_a = premium_data_fixture.create_select_option( @@ -239,9 +236,7 @@ def test_get_rows_grouped_by_single_select_field_with_empty_table( ): user = premium_data_fixture.create_user() table = premium_data_fixture.create_database_table() - view = View() - view.id = 999 # fake pk - view.table = table + view = premium_data_fixture.create_grid_view(table=table) single_select_field = premium_data_fixture.create_single_select_field(table=table) rows = get_rows_grouped_by_single_select_field(user, view, single_select_field) assert len(rows) == 1 diff --git a/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/kanban.scss b/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/kanban.scss index 63692f0920..ea2e0d11b1 100644 --- a/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/kanban.scss +++ b/premium/web-frontend/modules/baserow_premium/assets/scss/components/views/kanban.scss @@ -54,6 +54,10 @@ border: solid 1px $palette-neutral-200; @include rounded($rounded-md); + + &--drag-over { + border-color: $palette-neutral-500; + } } .kanban-view__stack-head { diff --git a/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewStack.vue b/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewStack.vue index 943110cc13..1180bd7dba 100644 --- a/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewStack.vue +++ b/premium/web-frontend/modules/baserow_premium/components/views/kanban/KanbanViewStack.vue @@ -12,7 +12,10 @@ >
@@ -155,6 +158,8 @@ import { populateRow } from '@baserow_premium/store/view/kanban' import KanbanViewStackContext from '@baserow_premium/components/views/kanban/KanbanViewStackContext' import { getCardHeight } from '@baserow/modules/database/utils/card' import viewDecoration from '@baserow/modules/database/mixins/viewDecoration' +import { clone } from '@baserow/modules/core/utils/object' +import { getRowSortFunction } from '@baserow/modules/database/utils/view' export default { name: 'KanbanViewStack', @@ -246,6 +251,32 @@ export default { this.$props.storePrefix + 'view/kanban/getDraggingOriginalStackId' ] }, + /** + * `true` when a card is currently being dragged and its live stack (the one + * it has been moved into by the drag handlers) matches this stack. Used to + * apply a visual indicator on the destination stack. + */ + isDragOver() { + if (this.draggingRow === null) { + return false + } + const findStackIdAndIndex = + this.$store.getters[ + this.storePrefix + 'view/kanban/findStackIdAndIndex' + ] + const result = findStackIdAndIndex(this.draggingRow.id) + return result !== undefined && result[0] === this.id + }, + /** + * When the view has one or more sortings configured, the vertical position + * of cards within a stack is determined by the sort. Reordering by drag is + * therefore disabled within a stack, but moving a card horizontally to a + * different stack is still allowed; the card snaps to its sort-determined + * position in the destination stack. + */ + dragRestrictedBySort() { + return this.view.sortings.length > 0 + }, singleSelectFieldId() { return this.$store.getters[ this.$props.storePrefix + 'view/kanban/getSingleSelectFieldId' @@ -375,6 +406,8 @@ export default { { row: this.draggingRow, originalStackId: this.draggingOriginalStackId, + view: this.view, + fields: this.fields, } ) } @@ -418,6 +451,7 @@ export default { { table: this.table, fields: this.fields, + view: this.view, } ) } catch (error) { @@ -447,34 +481,77 @@ export default { return } + const findStackIdAndIndex = + this.$store.getters[ + this.storePrefix + 'view/kanban/findStackIdAndIndex' + ] + const targetStackId = findStackIdAndIndex(row.id)[0] + const draggingStackId = findStackIdAndIndex(this.draggingRow.id)[0] + // If the field is read_only, it's not possible to move between stacks because // they would change the read_only value. const fieldType = this.$registry.get('field', this.singleSelectField.type) - if (!fieldType.canWriteFieldValues(this.singleSelectField)) { - const target = this.$store.getters[ - this.storePrefix + 'view/kanban/findStackIdAndIndex' - ](row.id) - if (target[0] !== this.draggingOriginalStackId) { + if ( + !fieldType.canWriteFieldValues(this.singleSelectField) && + targetStackId !== this.draggingOriginalStackId + ) { + return + } + + let moved = false + if (this.dragRestrictedBySort) { + // With sortings active, cards within a stack are kept in sort order, + // so we don't reorder vertically. We only let the dragged card move to + // another stack, where it's placed at the sort-determined position. + if (targetStackId === draggingStackId) { return } + moved = await this.$store.dispatch( + this.storePrefix + 'view/kanban/forceMoveRowTo', + { + row: this.draggingRow, + targetStackId, + targetIndex: this.sortedIndexInStack(targetStackId), + } + ) + } else { + const rect = event.target.getBoundingClientRect() + const top = event.clientY - rect.top + const half = rect.height / 2 + const before = top <= half + moved = await this.$store.dispatch( + this.storePrefix + 'view/kanban/forceMoveRowBefore', + { + row: this.draggingRow, + targetRow: row, + targetBefore: before, + } + ) } - const rect = event.target.getBoundingClientRect() - const top = event.clientY - rect.top - const half = rect.height / 2 - const before = top <= half - const moved = await this.$store.dispatch( - this.storePrefix + 'view/kanban/forceMoveRowBefore', - { - row: this.draggingRow, - targetRow: row, - targetBefore: before, - } - ) if (moved) { this.moved(event) } }, + /** + * Computes the index where the dragging row should be inserted in the + * provided stack so that the order matches the view sortings. The dragging + * row is excluded from the existing stack rows before the lookup so that + * it's positioned consistently regardless of where it currently lives. + */ + sortedIndexInStack(stackId) { + const stack = + this.$store.getters[this.storePrefix + 'view/kanban/getStack'](stackId) + const candidates = clone( + stack.results.filter((r) => r.id !== this.draggingRow.id) + ) + candidates.push(this.draggingRow) + candidates.sort( + getRowSortFunction(this.$registry, this.view.sortings, this.fields) + ) + const index = candidates.findIndex((r) => r.id === this.draggingRow.id) + return index === -1 ? candidates.length - 1 : index + }, /** * When dragging a row over an empty stack, we want to move that row into it. * Normally the row is only moved when it's being dragged over an existing card, diff --git a/premium/web-frontend/modules/baserow_premium/services/views/kanban.js b/premium/web-frontend/modules/baserow_premium/services/views/kanban.js index dcbfa39c9f..0b91a1869b 100644 --- a/premium/web-frontend/modules/baserow_premium/services/views/kanban.js +++ b/premium/web-frontend/modules/baserow_premium/services/views/kanban.js @@ -16,6 +16,7 @@ export default (client) => { selectOptions = [], publicUrl = false, publicAuthToken = null, + orderBy = null, filters = {}, limitLinkedItems = null, }) { @@ -27,6 +28,10 @@ export default (client) => { params.append('offset', offset) } + if (orderBy !== null && orderBy !== '') { + params.append('order_by', orderBy) + } + if (includeFieldOptions) { include.push('field_options') } diff --git a/premium/web-frontend/modules/baserow_premium/store/view/kanban.js b/premium/web-frontend/modules/baserow_premium/store/view/kanban.js index 3cff90d165..3210d13407 100644 --- a/premium/web-frontend/modules/baserow_premium/store/view/kanban.js +++ b/premium/web-frontend/modules/baserow_premium/store/view/kanban.js @@ -6,6 +6,7 @@ import KanbanService from '@baserow_premium/services/views/kanban' import { extractRowMetadata, getFilters, + getOrderBy, getRowSortFunction, matchSearchFilters, } from '@baserow/modules/database/utils/view' @@ -54,6 +55,8 @@ export const state = () => ({ draggingOriginalBefore: null, // If true, ad hoc filtering is used instead of persistent one adhocFiltering: false, + // If true, ad hoc sorting is used + adhocSorting: false, // Indicates whether row(s) are currently being created. creating: false, }) @@ -201,6 +204,9 @@ export const mutations = { SET_ADHOC_FILTERING(state, adhocFiltering) { state.adhocFiltering = adhocFiltering }, + SET_ADHOC_SORTING(state, adhocSorting) { + state.adhocSorting = adhocSorting + }, SET_CREATING(state, value) { state.creating = value }, @@ -224,10 +230,12 @@ export const actions = { kanbanId, singleSelectFieldId, adhocFiltering, + adhocSorting, includeFieldOptions = true, } ) { commit('SET_ADHOC_FILTERING', adhocFiltering) + commit('SET_ADHOC_SORTING', adhocSorting) commit('SET_LAST_KANBAN_ID', kanbanId) const { $client } = this const view = rootGetters['view/get'](kanbanId) @@ -239,6 +247,7 @@ export const actions = { selectOptions: [], publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], + orderBy: getOrderBy(view, adhocSorting), filters: getFilters(view, adhocFiltering), }) // Don't do anything if the kanbanId does not match the current view kanbanId @@ -283,6 +292,7 @@ export const actions = { ], publicUrl: rootGetters['page/view/public/getIsPublic'], publicAuthToken: rootGetters['page/view/public/getAuthToken'], + orderBy: getOrderBy(view, getters.getAdhocSorting), filters: getFilters(view, getters.getAdhocFiltering), }) // Don't do anything if the kanbanId does not match the current view kanbanId @@ -628,7 +638,7 @@ export const actions = { } newStackResults.push(newRow) newStackCount++ - newStackResults.sort(getRowSortFunction($registry, [], fields)) + newStackResults.sort(getRowSortFunction($registry, view.sortings, fields)) const newIndex = newStackResults.findIndex((r) => r.id === newRow.id) const newIsLast = newIndex === newStackResults.length - 1 const newExists = @@ -712,13 +722,17 @@ export const actions = { * need to updated and will make a call to the backend. If something goes wrong, * the row is moved back to the original stack and position. */ - async stopRowDrag({ dispatch, commit, getters }, { table, fields }) { + async stopRowDrag({ dispatch, commit, getters }, { table, fields, view }) { const row = getters.getDraggingRow if (row === null) { return } const { $client, $registry } = this + // When the view has one or more sortings the vertical position of cards is + // determined by the sort, so we must not push a manual position to the + // backend; the row's `order` field is left untouched. + const sortingsActive = view.sortings.length > 0 // First we need to figure out what the current position of the row is and how // that should be communicated to the backend later. The backend expects another @@ -785,16 +799,19 @@ export const actions = { // If for whatever reason updating the value fails, we need to undo the // things that have changed in the store. commit('UPDATE_ROW', { row, values: oldValues }) - dispatch('cancelRowDrag', { row, originalStackId }) + dispatch('cancelRowDrag', { row, originalStackId, view, fields }) throw error } } // If the row is not before the same or if the stack has changed, we must update - // the position. + // the position. With sortings active the position is determined by the sort, + // so we skip the move call entirely and let the row stay where the local + // sort placed it. if ( - (before || { id: null }).id !== (originalBefore || { id: null }).id || - originalStackId !== currentStackId + !sortingsActive && + ((before || { id: null }).id !== (originalBefore || { id: null }).id || + originalStackId !== currentStackId) ) { try { const { data } = await RowService($client).move( @@ -804,16 +821,33 @@ export const actions = { ) commit('UPDATE_ROW', { row, values: data }) } catch (error) { - dispatch('cancelRowDrag', { row, originalStackId }) + dispatch('cancelRowDrag', { row, originalStackId, view, fields }) throw error } } + + const rowMovedToAnotherStack = originalStackId !== currentStackId + if (sortingsActive && rowMovedToAnotherStack) { + const targetStack = getters.getStack(currentStackId) + const droppedRowIsLast = currentIndex === targetStack.results.length - 1 + const targetStackHasUnloadedRows = + targetStack.results.length < targetStack.count + + if (droppedRowIsLast && targetStackHasUnloadedRows) { + // The sorted position might be among the unloaded rows. Remove the + // optimistic card until the next page fetch returns its real position. + commit('DELETE_ROW', { stackId: currentStackId, index: currentIndex }) + } + } }, /** * Cancels the current row drag action by reverting back to the original position * while respecting any new rows that have been moved into there in the mean time. */ - cancelRowDrag({ dispatch, getters, commit }, { row, originalStackId }) { + cancelRowDrag( + { dispatch, getters, commit }, + { row, originalStackId, view = null, fields = [] } + ) { const current = getters.findStackIdAndIndex(row.id) if (current !== undefined) { @@ -825,7 +859,8 @@ export const actions = { sortedRows.push(row) } const { $registry } = this - sortedRows.sort(getRowSortFunction($registry, [], [], null)) + const sortings = view?.sortings || [] + sortedRows.sort(getRowSortFunction($registry, sortings, fields)) const targetIndex = sortedRows.findIndex((r) => r.id === row.id) dispatch('forceMoveRowTo', { @@ -1156,6 +1191,9 @@ export const getters = { getAdhocFiltering(state) { return state.adhocFiltering }, + getAdhocSorting(state) { + return state.adhocSorting + }, getCreating(state) { return state.creating }, diff --git a/premium/web-frontend/modules/baserow_premium/viewTypes.js b/premium/web-frontend/modules/baserow_premium/viewTypes.js index b1561780c4..ed6abfeb8e 100644 --- a/premium/web-frontend/modules/baserow_premium/viewTypes.js +++ b/premium/web-frontend/modules/baserow_premium/viewTypes.js @@ -13,6 +13,7 @@ import PremiumFeatures from '@baserow_premium/features' import PaidFeaturesModal from '@baserow_premium/components/PaidFeaturesModal' import { isAdhocFiltering, + isAdhocSorting, maxPossibleOrderValue, } from '@baserow/modules/database/utils/view' import CalendarCreateIcalSharedViewLink from '@baserow_premium/components/views/calendar/CalendarCreateIcalSharedViewLink' @@ -76,7 +77,7 @@ export class KanbanViewType extends PremiumViewType { } canSort() { - return false + return true } canShare() { @@ -107,6 +108,12 @@ export class KanbanViewType extends PremiumViewType { view, isPublic ) + const adhocSorting = isAdhocSorting( + this.app, + database.workspace, + view, + isPublic + ) // If the single select field is `null` we can't fetch the initial data anyway, // we don't have to do anything. The KanbanView component will handle it by // showing a form to choose or create a single select field. @@ -117,6 +124,7 @@ export class KanbanViewType extends PremiumViewType { kanbanId: view.id, singleSelectFieldId: view.single_select_field, adhocFiltering, + adhocSorting, }) } } @@ -137,12 +145,19 @@ export class KanbanViewType extends PremiumViewType { view, isPublic ) + const adhocSorting = isAdhocSorting( + this.app, + database.workspace, + view, + isPublic + ) try { await store.dispatch(storePrefix + 'view/kanban/fetchInitial', { kanbanId: view.id, singleSelectFieldId: view.single_select_field, includeFieldOptions, adhocFiltering, + adhocSorting, }) } catch (error) { if ( diff --git a/premium/web-frontend/test/fixtures/kanban.js b/premium/web-frontend/test/fixtures/kanban.js index 030c101325..b23e1ab792 100644 --- a/premium/web-frontend/test/fixtures/kanban.js +++ b/premium/web-frontend/test/fixtures/kanban.js @@ -48,7 +48,7 @@ export function createKanbanView( colorClass: 'color-success', name: 'Kanban', canFilter: true, - canSort: false, + canSort: true, canShare: true, canGroupBy: false, }, diff --git a/premium/web-frontend/test/unit/premium/store/view/kanban.spec.js b/premium/web-frontend/test/unit/premium/store/view/kanban.spec.js index eeef3db04a..308bb18a85 100644 --- a/premium/web-frontend/test/unit/premium/store/view/kanban.spec.js +++ b/premium/web-frontend/test/unit/premium/store/view/kanban.spec.js @@ -7,6 +7,7 @@ describe('Kanban view store', () => { const view = { filters: [], filters_disabled: false, + sortings: [], } beforeEach(() => { @@ -278,4 +279,76 @@ describe('Kanban view store', () => { expect(store.state.kanban.stacks['1'].results[0].id).toBe(2) expect(store.state.kanban.stacks['1'].results[1].id).toBe(10) }) + + test('stopRowDrag with sortings drops the row from a partial buffer and skips the move call', async () => { + // Stack 1 represents a partially-loaded destination: 2 rows visible out of 50. + const draggingRow = { + id: 5, + order: '5.00', + field_1: { id: 1 }, + _: { dragging: true }, + } + const stacks = { + null: { count: 0, results: [] }, + // The dragging row was already moved into the destination stack at the + // last loaded index by the in-progress drag (cardMoveOver). + 1: { + count: 50, + results: [ + { id: 10, order: '10.00', field_1: { id: 1 } }, + { id: 11, order: '11.00', field_1: { id: 1 } }, + draggingRow, + ], + }, + } + const state = Object.assign(kanbanStore.state(), { + lastKanbanId: 1, + singleSelectFieldId: 1, + stacks, + // Simulate the drag state the same way startRowDrag would have set it. + draggingRow, + draggingOriginalStackId: 'null', + draggingOriginalBefore: null, + }) + store.replaceState({ ...store.state, kanban: state }) + + const fields = [ + { + id: 1, + type: 'single_select', + select_options: [{ id: 1, value: 'A', color: 'blue' }], + }, + ] + const table = { id: 99 } + const view = { sortings: [{ id: 1, field: 1, order: 'ASC' }] } + + let movePatchHit = false + let updatePatchHit = false + // The single-select update endpoint must be called once; the row move + // endpoint must NOT be called when sortings are active. + testApp.mock + .onPatch(`/database/rows/table/${table.id}/${draggingRow.id}/`) + .reply((config) => { + updatePatchHit = true + return [200, { id: draggingRow.id, ...JSON.parse(config.data) }] + }) + testApp.mock + .onPatch(`/database/rows/table/${table.id}/${draggingRow.id}/move/`) + .reply(() => { + movePatchHit = true + return [200, draggingRow] + }) + + await store.dispatch('kanban/stopRowDrag', { table, fields, view }) + + expect(updatePatchHit).toBe(true) + expect(movePatchHit).toBe(false) + // The row was at the last loaded index of a partial buffer, so it has + // been removed from the destination's visible buffer; the count stays + // unchanged because the row is still in that stack on the server. + expect(store.state.kanban.stacks['1'].count).toBe(50) + expect(store.state.kanban.stacks['1'].results.map((r) => r.id)).toEqual([ + 10, 11, + ]) + }) }) diff --git a/premium/web-frontend/test/unit/premium/view/kanban/kanbanViewStack.spec.js b/premium/web-frontend/test/unit/premium/view/kanban/kanbanViewStack.spec.js new file mode 100644 index 0000000000..679df1c28c --- /dev/null +++ b/premium/web-frontend/test/unit/premium/view/kanban/kanbanViewStack.spec.js @@ -0,0 +1,194 @@ +import KanbanViewStack from '@baserow_premium/components/views/kanban/KanbanViewStack.vue' + +describe('KanbanViewStack drag behaviour', () => { + test('dragRestrictedBySort returns true when the view has sortings', () => { + const view = { sortings: [{ id: 1, field: 1, order: 'ASC' }] } + expect(KanbanViewStack.computed.dragRestrictedBySort.call({ view })).toBe( + true + ) + }) + + test('dragRestrictedBySort returns false when the view has no sortings', () => { + const view = { sortings: [] } + expect(KanbanViewStack.computed.dragRestrictedBySort.call({ view })).toBe( + false + ) + }) + + test('cardMoveOver does not reorder vertically within a stack when sortings are active', async () => { + const dispatched = [] + const draggingRow = { id: 1 } + const targetRow = { id: 2 } + // The dragging row and the target row both belong to stack `stack-a`. + const findStackIdAndIndex = (id) => { + if (id === draggingRow.id) return ['stack-a', 0] + if (id === targetRow.id) return ['stack-a', 1] + return undefined + } + + const context = { + draggingRow, + draggingOriginalStackId: 'stack-a', + dragRestrictedBySort: true, + storePrefix: 'page/', + view: { sortings: [{ field: 1, order: 'ASC' }] }, + fields: [], + $registry: { get: () => ({ canWriteFieldValues: () => true }) }, + singleSelectField: { type: 'single_select' }, + $store: { + getters: { + 'page/view/kanban/findStackIdAndIndex': findStackIdAndIndex, + }, + dispatch: (action, payload) => { + dispatched.push({ action, payload }) + return Promise.resolve(true) + }, + }, + } + const event = { + target: { transitioning: false, getBoundingClientRect: () => ({}) }, + } + + await KanbanViewStack.methods.cardMoveOver.call(context, event, targetRow) + + expect(dispatched).toEqual([]) + }) + + test('cardMoveOver moves a row to a different stack at the sort-determined index when sortings are active', async () => { + const dispatched = [] + const draggingRow = { id: 1 } + const targetRow = { id: 2 } + const findStackIdAndIndex = (id) => { + if (id === draggingRow.id) return ['stack-a', 0] + if (id === targetRow.id) return ['stack-b', 0] + return undefined + } + + const context = { + draggingRow, + draggingOriginalStackId: 'stack-a', + dragRestrictedBySort: true, + storePrefix: 'page/', + view: { sortings: [{ field: 99, order: 'ASC', type: 'default' }] }, + fields: [], + $registry: { get: () => ({ canWriteFieldValues: () => true }) }, + singleSelectField: { type: 'single_select' }, + $store: { + getters: { + 'page/view/kanban/findStackIdAndIndex': findStackIdAndIndex, + }, + dispatch: (action, payload) => { + dispatched.push({ action, payload }) + return Promise.resolve(true) + }, + }, + moved: vi.fn(), + // The component delegates the sort lookup to this helper; we stub it + // here so the test focuses on the cross-stack dispatching logic. + sortedIndexInStack: vi.fn(() => 1), + } + const event = { + target: { transitioning: false, getBoundingClientRect: () => ({}) }, + } + + await KanbanViewStack.methods.cardMoveOver.call(context, event, targetRow) + + expect(context.sortedIndexInStack).toHaveBeenCalledWith('stack-b') + expect(dispatched).toHaveLength(1) + expect(dispatched[0].action).toBe('page/view/kanban/forceMoveRowTo') + expect(dispatched[0].payload).toEqual({ + row: draggingRow, + targetStackId: 'stack-b', + targetIndex: 1, + }) + }) + + test('sortedIndexInStack returns the sort-determined index for the dragging row', () => { + const draggingRow = { id: 1, field_99: 'M' } + const stackResults = [ + { id: 2, field_99: 'A' }, + { id: 3, field_99: 'Z' }, + ] + const sortField = { id: 99, type: 'text' } + + const context = { + draggingRow, + storePrefix: 'page/', + view: { sortings: [{ field: 99, order: 'ASC', type: 'default' }] }, + fields: [sortField], + $registry: { + get: (registry, type) => { + if (registry === 'field' && type === 'text') { + return { + getSortTypes: () => ({ + default: { + function: (fieldName, order) => (a, b) => { + const valueA = a[fieldName] ?? '' + const valueB = b[fieldName] ?? '' + const cmp = valueA.localeCompare(valueB) + return order === 'ASC' ? cmp : -cmp + }, + }, + }), + } + } + return {} + }, + }, + $store: { + getters: { + 'page/view/kanban/getStack': () => ({ results: stackResults }), + }, + }, + } + + // 'M' sorts between 'A' and 'Z', so the dragging row should land at index 1. + expect( + KanbanViewStack.methods.sortedIndexInStack.call(context, 'stack-b') + ).toBe(1) + }) + + test('cardMoveOver falls back to default before/after behaviour when no sortings are configured', async () => { + const dispatched = [] + const draggingRow = { id: 1 } + const targetRow = { id: 2 } + const findStackIdAndIndex = (id) => { + if (id === draggingRow.id) return ['stack-a', 0] + if (id === targetRow.id) return ['stack-a', 1] + return undefined + } + const context = { + draggingRow, + draggingOriginalStackId: 'stack-a', + dragRestrictedBySort: false, + storePrefix: 'page/', + view: { sortings: [] }, + fields: [], + $registry: { get: () => ({ canWriteFieldValues: () => true }) }, + singleSelectField: { type: 'single_select' }, + $store: { + getters: { + 'page/view/kanban/findStackIdAndIndex': findStackIdAndIndex, + }, + dispatch: (action, payload) => { + dispatched.push({ action, payload }) + return Promise.resolve(true) + }, + }, + moved: vi.fn(), + } + const event = { + clientY: 5, + target: { + transitioning: false, + getBoundingClientRect: () => ({ top: 0, height: 20 }), + }, + } + + await KanbanViewStack.methods.cardMoveOver.call(context, event, targetRow) + + expect(dispatched).toHaveLength(1) + expect(dispatched[0].action).toBe('page/view/kanban/forceMoveRowBefore') + expect(dispatched[0].payload.targetBefore).toBe(true) + }) +})