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)
+ })
+})