Skip to content

Commit 6ac5a3b

Browse files
authored
feat: Add sort to kanban view (baserow#5341)
1 parent e240757 commit 6ac5a3b

13 files changed

Lines changed: 714 additions & 38 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "feature",
3+
"message": "Add sort to kanban view",
4+
"issue_origin": "github",
5+
"issue_number": 764,
6+
"domain": "database",
7+
"bullet_points": [],
8+
"created_at": "2026-05-10"
9+
}

premium/backend/src/baserow_premium/api/views/kanban/views.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@
1010
from baserow.contrib.database.api.constants import (
1111
ADHOC_FILTERS_API_PARAMS,
1212
ADHOC_FILTERS_API_PARAMS_NO_COMBINE,
13+
ADHOC_SORTING_API_PARAM,
1314
LIMIT_LINKED_ITEMS_API_PARAM,
1415
)
1516
from baserow.contrib.database.api.fields.errors import (
1617
ERROR_FIELD_DOES_NOT_EXIST,
1718
ERROR_FILTER_FIELD_NOT_FOUND,
19+
ERROR_ORDER_BY_FIELD_NOT_FOUND,
20+
ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
1821
)
1922
from baserow.contrib.database.api.rows.serializers import (
2023
RowSerializer,
@@ -33,6 +36,8 @@
3336
from baserow.contrib.database.fields.exceptions import (
3437
FieldDoesNotExist,
3538
FilterFieldNotFound,
39+
OrderByFieldNotFound,
40+
OrderByFieldNotPossible,
3641
)
3742
from baserow.contrib.database.rows.registries import row_metadata_registry
3843
from baserow.contrib.database.table.operations import ListRowsDatabaseTableOperationType
@@ -303,6 +308,7 @@ class PublicKanbanViewView(APIView):
303308
),
304309
),
305310
*ADHOC_FILTERS_API_PARAMS,
311+
ADHOC_SORTING_API_PARAM,
306312
LIMIT_LINKED_ITEMS_API_PARAM,
307313
],
308314
tags=["Database table kanban view"],
@@ -320,6 +326,8 @@ class PublicKanbanViewView(APIView):
320326
[
321327
"ERROR_USER_NOT_IN_GROUP",
322328
"ERROR_KANBAN_VIEW_HAS_NO_SINGLE_SELECT_FIELD",
329+
"ERROR_ORDER_BY_FIELD_NOT_FOUND",
330+
"ERROR_ORDER_BY_FIELD_NOT_POSSIBLE",
323331
"ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST",
324332
"ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD",
325333
"ERROR_FILTER_FIELD_NOT_FOUND",
@@ -340,6 +348,8 @@ class PublicKanbanViewView(APIView):
340348
ERROR_NO_AUTHORIZATION_TO_PUBLICLY_SHARED_VIEW
341349
),
342350
FilterFieldNotFound: ERROR_FILTER_FIELD_NOT_FOUND,
351+
OrderByFieldNotFound: ERROR_ORDER_BY_FIELD_NOT_FOUND,
352+
OrderByFieldNotPossible: ERROR_ORDER_BY_FIELD_NOT_POSSIBLE,
343353
ViewFilterTypeDoesNotExist: ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST,
344354
ViewFilterTypeNotAllowedForField: ERROR_VIEW_FILTER_TYPE_UNSUPPORTED_FIELD,
345355
FieldDoesNotExist: ERROR_FIELD_DOES_NOT_EXIST,
@@ -353,6 +363,7 @@ def get(self, request, slug: str, field_options: bool):
353363
"""
354364

355365
adhoc_filters = AdHocFilters.from_request(request)
366+
order_by = request.GET.get("order_by")
356367

357368
view_handler = ViewHandler()
358369
view = view_handler.get_public_view_by_slug(
@@ -384,6 +395,7 @@ def get(self, request, slug: str, field_options: bool):
384395
) = ViewHandler().get_public_rows_queryset_and_field_ids(
385396
view,
386397
adhoc_filters=adhoc_filters,
398+
order_by=order_by,
387399
table_model=model,
388400
view_type=view_type,
389401
)
@@ -407,6 +419,7 @@ def get(self, request, slug: str, field_options: bool):
407419
default_offset=default_offset,
408420
model=model,
409421
base_queryset=queryset,
422+
apply_view_sorts=not order_by,
410423
)
411424

412425
for key, value in rows.items():

premium/backend/src/baserow_premium/views/handler.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def get_rows_grouped_by_single_select_field(
3838
adhoc_filters: Optional[AdHocFilters] = None,
3939
model: Optional[GeneratedTableModel] = None,
4040
base_queryset: Optional[QuerySet] = None,
41+
apply_view_sorts: bool = True,
4142
) -> Dict[str, Dict[str, Union[int, list]]]:
4243
"""
4344
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(
7475
:param base_queryset: Optionally an alternative base queryset can be provided
7576
that will be used to fetch the rows. This should be provided if additional
7677
filters and/or sorts must be added.
78+
:param apply_view_sorts: When `True` (the default) the view's own sorts are
79+
applied to the queryset. Set to `False` when the caller has already
80+
ordered the queryset (e.g. via an adhoc `order_by` query parameter) so
81+
that the explicit ordering is preserved.
7782
:return: The fetched rows including the total count.
7883
"""
7984

@@ -86,7 +91,10 @@ def get_rows_grouped_by_single_select_field(
8691
model = table.get_model()
8792

8893
if base_queryset is None:
89-
base_queryset = model.objects.all().enhance_by_fields().order_by("order", "id")
94+
base_queryset = model.objects.all().enhance_by_fields()
95+
96+
if apply_view_sorts:
97+
base_queryset = ViewHandler().apply_sorting(view, base_queryset)
9098

9199
if adhoc_filters is None:
92100
adhoc_filters = AdHocFilters()

premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2531,3 +2531,248 @@ def test_reference_to_single_select_field_is_removed_after_trashing(
25312531
json_response = response.json()
25322532

25332533
assert len(json_response) == 0
2534+
2535+
2536+
@pytest.mark.django_db
2537+
@override_settings(DEBUG=True)
2538+
def test_list_rows_applies_view_sortings_per_stack(api_client, premium_data_fixture):
2539+
user, token = premium_data_fixture.create_user_and_token(
2540+
has_active_premium_license=True
2541+
)
2542+
table = premium_data_fixture.create_database_table(user=user)
2543+
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
2544+
single_select_field = premium_data_fixture.create_single_select_field(table=table)
2545+
option_a = premium_data_fixture.create_select_option(
2546+
field=single_select_field, value="A", color="blue"
2547+
)
2548+
kanban = premium_data_fixture.create_kanban_view(
2549+
table=table, single_select_field=single_select_field
2550+
)
2551+
premium_data_fixture.create_view_sort(view=kanban, field=text_field, order="ASC")
2552+
2553+
model = table.get_model()
2554+
# Insert rows in unsorted order; the view sort should reorder them.
2555+
row_a_c = model.objects.create(
2556+
**{
2557+
f"field_{text_field.id}": "C",
2558+
f"field_{single_select_field.id}_id": option_a.id,
2559+
}
2560+
)
2561+
row_a_a = model.objects.create(
2562+
**{
2563+
f"field_{text_field.id}": "A",
2564+
f"field_{single_select_field.id}_id": option_a.id,
2565+
}
2566+
)
2567+
row_a_b = model.objects.create(
2568+
**{
2569+
f"field_{text_field.id}": "B",
2570+
f"field_{single_select_field.id}_id": option_a.id,
2571+
}
2572+
)
2573+
row_null_b = model.objects.create(
2574+
**{
2575+
f"field_{text_field.id}": "B",
2576+
f"field_{single_select_field.id}_id": None,
2577+
}
2578+
)
2579+
row_null_a = model.objects.create(
2580+
**{
2581+
f"field_{text_field.id}": "A",
2582+
f"field_{single_select_field.id}_id": None,
2583+
}
2584+
)
2585+
2586+
url = reverse("api:database:views:kanban:list", kwargs={"view_id": kanban.id})
2587+
response = api_client.get(url, **{"HTTP_AUTHORIZATION": f"JWT {token}"})
2588+
response_json = response.json()
2589+
assert response.status_code == HTTP_200_OK
2590+
2591+
null_results = response_json["rows"]["null"]["results"]
2592+
assert [r["id"] for r in null_results] == [row_null_a.id, row_null_b.id]
2593+
2594+
a_results = response_json["rows"][str(option_a.id)]["results"]
2595+
assert [r["id"] for r in a_results] == [row_a_a.id, row_a_b.id, row_a_c.id]
2596+
2597+
2598+
@pytest.mark.django_db
2599+
@override_settings(DEBUG=True)
2600+
def test_list_public_rows_applies_view_sortings_per_stack(
2601+
api_client, premium_data_fixture
2602+
):
2603+
user, _ = premium_data_fixture.create_user_and_token()
2604+
table = premium_data_fixture.create_database_table(user=user)
2605+
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
2606+
single_select_field = premium_data_fixture.create_single_select_field(table=table)
2607+
option_a = premium_data_fixture.create_select_option(
2608+
field=single_select_field, value="A", color="blue"
2609+
)
2610+
kanban_view = premium_data_fixture.create_kanban_view(
2611+
table=table,
2612+
user=user,
2613+
public=True,
2614+
single_select_field=single_select_field,
2615+
)
2616+
premium_data_fixture.create_kanban_view_field_option(
2617+
kanban_view, text_field, hidden=False
2618+
)
2619+
premium_data_fixture.create_view_sort(
2620+
view=kanban_view, field=text_field, order="DESC"
2621+
)
2622+
2623+
model = table.get_model()
2624+
row_a_a = model.objects.create(
2625+
**{
2626+
f"field_{text_field.id}": "A",
2627+
f"field_{single_select_field.id}_id": option_a.id,
2628+
}
2629+
)
2630+
row_a_c = model.objects.create(
2631+
**{
2632+
f"field_{text_field.id}": "C",
2633+
f"field_{single_select_field.id}_id": option_a.id,
2634+
}
2635+
)
2636+
row_a_b = model.objects.create(
2637+
**{
2638+
f"field_{text_field.id}": "B",
2639+
f"field_{single_select_field.id}_id": option_a.id,
2640+
}
2641+
)
2642+
2643+
response = api_client.get(
2644+
reverse(
2645+
"api:database:views:kanban:public_rows",
2646+
kwargs={"slug": kanban_view.slug},
2647+
)
2648+
)
2649+
response_json = response.json()
2650+
assert response.status_code == HTTP_200_OK
2651+
2652+
a_results = response_json["rows"][str(option_a.id)]["results"]
2653+
assert [r["id"] for r in a_results] == [row_a_c.id, row_a_b.id, row_a_a.id]
2654+
2655+
2656+
@pytest.mark.django_db
2657+
@override_settings(DEBUG=True)
2658+
def test_list_public_rows_adhoc_order_by_overrides_view_sortings(
2659+
api_client, premium_data_fixture
2660+
):
2661+
user, _ = premium_data_fixture.create_user_and_token()
2662+
table = premium_data_fixture.create_database_table(user=user)
2663+
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
2664+
single_select_field = premium_data_fixture.create_single_select_field(table=table)
2665+
option_a = premium_data_fixture.create_select_option(
2666+
field=single_select_field, value="A", color="blue"
2667+
)
2668+
kanban_view = premium_data_fixture.create_kanban_view(
2669+
table=table,
2670+
user=user,
2671+
public=True,
2672+
single_select_field=single_select_field,
2673+
)
2674+
premium_data_fixture.create_kanban_view_field_option(
2675+
kanban_view, text_field, hidden=False
2676+
)
2677+
# The view's own sort is DESC; the adhoc `order_by` query parameter should
2678+
# override this and sort ASC instead.
2679+
premium_data_fixture.create_view_sort(
2680+
view=kanban_view, field=text_field, order="DESC"
2681+
)
2682+
2683+
model = table.get_model()
2684+
row_a_b = model.objects.create(
2685+
**{
2686+
f"field_{text_field.id}": "B",
2687+
f"field_{single_select_field.id}_id": option_a.id,
2688+
}
2689+
)
2690+
row_a_a = model.objects.create(
2691+
**{
2692+
f"field_{text_field.id}": "A",
2693+
f"field_{single_select_field.id}_id": option_a.id,
2694+
}
2695+
)
2696+
row_a_c = model.objects.create(
2697+
**{
2698+
f"field_{text_field.id}": "C",
2699+
f"field_{single_select_field.id}_id": option_a.id,
2700+
}
2701+
)
2702+
2703+
response = api_client.get(
2704+
reverse(
2705+
"api:database:views:kanban:public_rows",
2706+
kwargs={"slug": kanban_view.slug},
2707+
)
2708+
+ f"?order_by=field_{text_field.id}"
2709+
)
2710+
response_json = response.json()
2711+
assert response.status_code == HTTP_200_OK
2712+
2713+
a_results = response_json["rows"][str(option_a.id)]["results"]
2714+
assert [r["id"] for r in a_results] == [row_a_a.id, row_a_b.id, row_a_c.id]
2715+
2716+
2717+
@pytest.mark.django_db
2718+
@override_settings(DEBUG=True)
2719+
def test_list_public_rows_adhoc_order_by_invalid_field(
2720+
api_client, premium_data_fixture
2721+
):
2722+
user, _ = premium_data_fixture.create_user_and_token()
2723+
table = premium_data_fixture.create_database_table(user=user)
2724+
single_select_field = premium_data_fixture.create_single_select_field(table=table)
2725+
kanban_view = premium_data_fixture.create_kanban_view(
2726+
table=table,
2727+
user=user,
2728+
public=True,
2729+
single_select_field=single_select_field,
2730+
)
2731+
2732+
response = api_client.get(
2733+
reverse(
2734+
"api:database:views:kanban:public_rows",
2735+
kwargs={"slug": kanban_view.slug},
2736+
)
2737+
+ "?order_by=field_999999"
2738+
)
2739+
response_json = response.json()
2740+
assert response.status_code == HTTP_400_BAD_REQUEST
2741+
assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND"
2742+
2743+
2744+
@pytest.mark.django_db
2745+
@override_settings(DEBUG=True)
2746+
def test_list_public_rows_adhoc_order_by_hidden_field_not_found(
2747+
api_client, premium_data_fixture
2748+
):
2749+
user, _ = premium_data_fixture.create_user_and_token()
2750+
table = premium_data_fixture.create_database_table(user=user)
2751+
text_field = premium_data_fixture.create_text_field(table=table, primary=True)
2752+
hidden_text_field = premium_data_fixture.create_text_field(table=table)
2753+
single_select_field = premium_data_fixture.create_single_select_field(table=table)
2754+
kanban_view = premium_data_fixture.create_kanban_view(
2755+
table=table,
2756+
user=user,
2757+
public=True,
2758+
single_select_field=single_select_field,
2759+
)
2760+
# Hide the secondary text field; sorting on it from the public endpoint
2761+
# should be rejected the same way as sorting on a non-existing field.
2762+
premium_data_fixture.create_kanban_view_field_option(
2763+
kanban_view, text_field, hidden=False
2764+
)
2765+
premium_data_fixture.create_kanban_view_field_option(
2766+
kanban_view, hidden_text_field, hidden=True
2767+
)
2768+
2769+
response = api_client.get(
2770+
reverse(
2771+
"api:database:views:kanban:public_rows",
2772+
kwargs={"slug": kanban_view.slug},
2773+
)
2774+
+ f"?order_by=field_{hidden_text_field.id}"
2775+
)
2776+
response_json = response.json()
2777+
assert response.status_code == HTTP_400_BAD_REQUEST
2778+
assert response_json["error"] == "ERROR_ORDER_BY_FIELD_NOT_FOUND"

0 commit comments

Comments
 (0)