Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 13 additions & 0 deletions premium/backend/src/baserow_premium/api/views/kanban/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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"],
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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,
)
Expand All @@ -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():
Expand Down
10 changes: 9 additions & 1 deletion premium/backend/src/baserow_premium/views/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading