From f7e442417f38df652e8af5c693995f770121605b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 20 Jul 2026 18:03:16 +0530 Subject: [PATCH 1/5] UN-3770 [FEAT] Opt-in pagination for list endpoints; wire Workflows page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four list endpoints (workflows, prompt studio, adapters, connectors) returned every row as a bare array and filtered client-side, making the pages slow to load as orgs grow. Backend: add `OptionalPagination` — it paginates only when the caller sends `?page`/`?page_size`, otherwise returns None so DRF serialises the bare array. These endpoints are shared with dropdown/selector consumers that expect an array, so the opt-in keeps their responses byte-identical while the listing page opts in and receives the {count,next,previous,results} envelope. Attach it to the four viewsets and add server-side name search; Workflow also gets a deterministic order (id tiebreaker) for stable pages. Frontend: the Workflows page now paginates + searches server-side, reusing the existing `usePaginatedList` hook and the Pipelines wiring pattern. This is the first vertical slice — the prompt-studio, adapters and connectors pages will follow (their backends already ship the dormant pagination). Test: utils/tests/test_pagination.py pins the opt-in contract (bare array without a page param; envelope with one). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzUpb2Dyunz2DCdwegR62Z --- backend/adapter_processor_v2/views.py | 7 + backend/connector_v2/views.py | 7 + .../prompt_studio_core_v2/views.py | 6 + backend/utils/pagination.py | 19 +++ backend/utils/tests/test_pagination.py | 55 +++++++ backend/workflow_manager/workflow_v2/views.py | 18 ++- .../workflows/workflow/Workflows.css | 6 + .../workflows/workflow/Workflows.jsx | 136 ++++++++++++------ .../workflows/workflow/workflow-service.js | 3 +- 9 files changed, 204 insertions(+), 53 deletions(-) create mode 100644 backend/utils/tests/test_pagination.py diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 41897f4b8f..8110fe27de 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -28,6 +28,7 @@ from tenant_account_v2.organization_member_service import OrganizationMemberService from tool_instance_v2.models import ToolInstance from utils.filtering import FilterHelper +from utils.pagination import OptionalPagination from utils.user_context import UserContext from adapter_processor_v2.adapter_processor import AdapterProcessor @@ -146,6 +147,7 @@ class AdapterInstanceViewSet( OwnerManagementMixin, ResourceShareManagementMixin, ModelViewSet ): serializer_class = AdapterInstanceSerializer + pagination_class = OptionalPagination notification_resource_name_field = "adapter_name" def get_notification_resource_type(self, resource: Any) -> str | None: @@ -187,6 +189,11 @@ def get_queryset(self) -> QuerySet | None: constant.ADAPTER_NAME, ): queryset = queryset.filter(**filter_args) + + # Server-side name search for the paginated listing page + search = self.request.query_params.get("search") + if search: + queryset = queryset.filter(adapter_name__icontains=search) return queryset def get_serializer_class( diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index 2ff7a18f3b..c628e1b231 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -21,6 +21,7 @@ from rest_framework.versioning import URLPathVersioning from tenant_account_v2.organization_member_service import OrganizationMemberService from utils.filtering import FilterHelper +from utils.pagination import OptionalPagination from utils.user_context import UserContext from backend.constants import RequestKey @@ -45,6 +46,7 @@ class ConnectorInstanceViewSet( ): versioning_class = URLPathVersioning serializer_class = ConnectorInstanceSerializer + pagination_class = OptionalPagination notification_resource_name_field = "connector_name" def get_notification_resource_type(self, resource: Any) -> str | None: @@ -103,6 +105,11 @@ def get_queryset(self) -> QuerySet | None: if filter_args: queryset = queryset.filter(**filter_args) + # Server-side name search for the paginated listing page + search = self.request.query_params.get("search") + if search: + queryset = queryset.filter(connector_name__icontains=search) + # Filter by connector_mode connector_mode_param = self.request.query_params.get("connector_mode") if connector_mode_param: diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 7faf3a23c7..56706b35ed 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -31,6 +31,7 @@ from tool_instance_v2.models import ToolInstance from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper from utils.hubspot_notify import notify_hubspot_event +from utils.pagination import OptionalPagination from utils.user_context import UserContext from utils.user_session import UserSessionUtils from workflow_manager.endpoint_v2.models import WorkflowEndpoint @@ -128,6 +129,7 @@ class PromptStudioCoreView( """Viewset to handle all Custom tool related operations.""" versioning_class = URLPathVersioning + pagination_class = OptionalPagination serializer_class = CustomToolSerializer notification_resource_name_field = "tool_name" @@ -172,6 +174,10 @@ def get_queryset(self) -> QuerySet | None: qs = qs.select_related("created_by").annotate( _prompt_count=Subquery(prompt_count_sq), ) + # Server-side name search for the paginated listing page + search = self.request.query_params.get("search") + if search: + qs = qs.filter(tool_name__icontains=search) return qs def get_object(self): diff --git a/backend/utils/pagination.py b/backend/utils/pagination.py index 2f87a93bb0..5b00781202 100644 --- a/backend/utils/pagination.py +++ b/backend/utils/pagination.py @@ -7,3 +7,22 @@ class CustomPagination(PageNumberPagination): page_size = Pagination.PAGE_SIZE page_size_query_param = Pagination.PAGE_SIZE_QUERY_PARAM max_page_size = Pagination.MAX_PAGE_SIZE + + +class OptionalPagination(CustomPagination): + """Paginate only when the caller opts in via ?page / ?page_size. + + These list endpoints are shared: besides the listing page they feed + dropdowns/selectors that expect a bare array. Returning None keeps that + response untouched for callers that don't ask for a page, while the + listing page opts in and gets the {count, next, previous, results} envelope. + """ + + def paginate_queryset(self, queryset, request, view=None): + params = request.query_params + if ( + self.page_query_param not in params + and self.page_size_query_param not in params + ): + return None + return super().paginate_queryset(queryset, request, view=view) diff --git a/backend/utils/tests/test_pagination.py b/backend/utils/tests/test_pagination.py new file mode 100644 index 0000000000..70fd5b0380 --- /dev/null +++ b/backend/utils/tests/test_pagination.py @@ -0,0 +1,55 @@ +"""Contract tests for utils.pagination.OptionalPagination. + +The four listing endpoints it backs (workflows, prompt studio, adapters, +connectors) are shared with dropdown/selector consumers that expect a bare +array. The opt-in behaviour below is what keeps those consumers regression-free: +no page/page_size param -> paginate_queryset returns None -> DRF serialises the +bare list; only an explicit opt-in gets the {count, next, previous, results} +envelope. + +Deliberately DB-free and Django-settings-free (a hand-rolled request stub, no +APIRequestFactory) so it runs in the rig's unit tier. +""" + +from __future__ import annotations + +from utils.pagination import OptionalPagination + +QUERYSET = list(range(1, 101)) # 100 sliceable items stand in for a queryset + + +class _Req: + """Minimal stand-in exposing the surface DRF pagination touches.""" + + def __init__(self, params: dict[str, str]): + self.query_params = params + + def build_absolute_uri(self) -> str: + return "http://testserver/things/" + + +class TestOptionalPagination: + def test_no_params_returns_none(self): + """No opt-in -> None, so callers keep their bare-array response.""" + assert OptionalPagination().paginate_queryset(QUERYSET, _Req({})) is None + + def test_unrelated_params_do_not_trigger_pagination(self): + req = _Req({"adapter_type": "LLM", "search": "foo"}) + assert OptionalPagination().paginate_queryset(QUERYSET, req) is None + + def test_page_param_opts_in(self): + paginator = OptionalPagination() + page = paginator.paginate_queryset(QUERYSET, _Req({"page": "1"})) + assert page == list(range(1, 51)) # default page_size == 50 + assert paginator.page.paginator.count == 100 + + def test_page_size_param_alone_opts_in(self): + page = OptionalPagination().paginate_queryset(QUERYSET, _Req({"page_size": "10"})) + assert page == list(range(1, 11)) + + def test_page_size_capped_at_max(self): + page = OptionalPagination().paginate_queryset( + QUERYSET, _Req({"page_size": "100000"}) + ) + # max_page_size (1000) caps the request; all 100 rows fit in one page + assert page == QUERYSET diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index 0db26a8507..6188c1fc6a 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -22,6 +22,7 @@ from rest_framework.views import APIView from utils.filtering import FilterHelper from utils.organization_utils import filter_queryset_by_organization, resolve_organization +from utils.pagination import OptionalPagination from backend.constants import RequestKey from unstract.core.data_models import FileHistoryCreateRequest @@ -75,6 +76,7 @@ class WorkflowViewSet( OwnerManagementMixin, ResourceShareManagementMixin, viewsets.ModelViewSet ): versioning_class = URLPathVersioning + pagination_class = OptionalPagination notification_resource_name_field = "workflow_name" def get_notification_resource_type(self, resource: Any) -> str | None: @@ -112,11 +114,19 @@ def get_queryset(self) -> QuerySet: queryset = queryset.select_related("created_by").prefetch_related( "memberships__user" ) + + # Server-side name search for the paginated listing page + search = self.request.query_params.get("search") + if search: + queryset = queryset.filter(workflow_name__icontains=search) + + # `id` tiebreaker keeps ordering deterministic across paginated requests + # (the for_user() manager uses plain .distinct(), so there is no default) order_by = self.request.query_params.get("order_by") - if order_by == "desc": - queryset = queryset.order_by("-modified_at") - elif order_by == "asc": - queryset = queryset.order_by("modified_at") + if order_by == "asc": + queryset = queryset.order_by("modified_at", "id") + else: + queryset = queryset.order_by("-modified_at", "id") return queryset diff --git a/frontend/src/components/workflows/workflow/Workflows.css b/frontend/src/components/workflows/workflow/Workflows.css index 22d6ec9e95..7a407a2fec 100644 --- a/frontend/src/components/workflows/workflow/Workflows.css +++ b/frontend/src/components/workflows/workflow/Workflows.css @@ -85,3 +85,9 @@ flex: 1; overflow-y: auto; } + +.workflows-pagination { + display: flex; + justify-content: flex-end; + padding: 12px 16px; +} diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index 4160cde4fa..e1efee0ec3 100644 --- a/frontend/src/components/workflows/workflow/Workflows.jsx +++ b/frontend/src/components/workflows/workflow/Workflows.jsx @@ -1,12 +1,12 @@ import { PlusOutlined, UserOutlined } from "@ant-design/icons"; -import { Typography } from "antd"; -import isEmpty from "lodash/isEmpty"; +import { Pagination, Typography } from "antd"; import PropTypes from "prop-types"; import { useCallback, useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx"; import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx"; +import { usePaginatedList } from "../../../hooks/usePaginatedList"; import usePostHogEvents from "../../../hooks/usePostHogEvents.js"; import { useInitialFetchCount, @@ -32,6 +32,8 @@ import "./Workflows.css"; const { Text } = Typography; +const DEFAULT_PAGE_SIZE = 12; + function Workflows() { const navigate = useNavigate(); const location = useLocation(); @@ -51,7 +53,8 @@ function Workflows() { const [editingProject, setEditProject] = useState(); const [loading, setLoading] = useState(false); const [openModal, toggleModal] = useState(true); - const projectListRef = useRef(); + // Ref forwards the fetch fn to the pagination hook (avoids declaration ordering) + const fetchListRef = useRef(null); const [backendErrors, setBackendErrors] = useState(null); const [shareOpen, setShareOpen] = useState(false); const [selectedWorkflow, setSelectedWorkflow] = useState(); @@ -61,10 +64,27 @@ function Workflows() { const [allGroups, setAllGroups] = useState([]); const { setAlertDetails } = useAlertStore(); + + const { + pagination, + setPagination, + searchTerm, + handlePaginationChange, + handleSearch, + } = usePaginatedList({ + fetchData: (...args) => fetchListRef.current?.(...args), + defaultPageSize: DEFAULT_PAGE_SIZE, + }); + + // Refresh the current page (preserves page + active search) after mutations const handleListRefresh = useCallback( - () => getProjectList(), - // eslint-disable-next-line react-hooks/exhaustive-deps - [], + () => + fetchListRef.current?.( + pagination.current, + pagination.pageSize, + searchTerm, + ), + [pagination.current, pagination.pageSize, searchTerm], ); const { coOwnerOpen, @@ -91,28 +111,39 @@ function Workflows() { } }, [location.pathname]); - function getProjectList() { + const getProjectList = ( + page = 1, + pageSize = DEFAULT_PAGE_SIZE, + search = "", + ) => { + setLoading(true); + const params = { page, page_size: pageSize }; + if (search) { + params.search = search; + } projectApiService - .getProjectList() + .getProjectList(params) .then((res) => { - projectListRef.current = res.data; - setProjectList(res.data); + const data = res?.data; + // Endpoint is opt-in paginated: envelope when we send ?page, else a + // bare array. Handle both so nothing breaks if the opt-in is dropped. + const results = data?.results ?? data ?? []; + setProjectList(results); + setPagination((prev) => ({ + ...prev, + current: page, + pageSize, + total: data?.count ?? results.length, + })); }) .catch(() => { console.error("Unable to get project list"); + }) + .finally(() => { + setLoading(false); }); - } - - function onSearch(searchText, setSearchList) { - if (!searchText.trim()) { - setSearchList(projectListRef.current); - return; - } - const filteredList = projectListRef.current.filter((item) => - item.workflow_name.toLowerCase().includes(searchText.toLowerCase()), - ); - setSearchList(filteredList); - } + }; + fetchListRef.current = getProjectList; function editProject(name, description) { setLoading(true); @@ -121,7 +152,7 @@ function Workflows() { .then((res) => { closeNewProject(); if (editingProject?.name) { - getProjectList(); + handleListRefresh(); } else { openProject(res.data); } @@ -129,7 +160,7 @@ function Workflows() { type: "success", content: "Workflow updated successfully", }); - getProjectList(); + handleListRefresh(); }) .catch((err) => { setAlertDetails( @@ -220,7 +251,7 @@ function Workflows() { projectApiService .deleteProject(project.id) .then(() => { - getProjectList(); + handleListRefresh(); setAlertDetails({ type: "success", content: "Workflow deleted successfully", @@ -307,7 +338,7 @@ function Workflows() { type: "success", content: "Workflow sharing updated successfully", }); - getProjectList(); + handleListRefresh(); // Close only on success; keep the modal open on failure so the user // can see the rejected entries and retry. setShareOpen(false); @@ -363,15 +394,13 @@ function Workflows() { handleSearch(value)} />
- {!projectListRef.current && } - {projectListRef.current && isEmpty(projectListRef?.current) && ( + {projectList === undefined && } + {projectList?.length === 0 && !searchTerm && (
)} - {isEmpty(projectList) && !isEmpty(projectListRef?.current) && ( + {projectList?.length === 0 && searchTerm && ( )} - {!isEmpty(projectList) && ( - + {projectList?.length > 0 && ( + <> + + {pagination.total > pagination.pageSize && ( +
+ +
+ )} + )} {editingProject && ( { - const params = myProjects ? { created_by: sessionDetails?.id } : {}; + getProjectList: (params = {}) => { options = { url: `${path}/workflow/`, method: "GET", From dab4e69e2ae2ba2734b99a6ced42e10dd1be7428 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 20 Jul 2026 19:51:42 +0530 Subject: [PATCH 2/5] UN-3770 [FIX] Use https in pagination test stub URL SonarCloud python:S5332 flags the clear-text http:// scheme in the request-stub build_absolute_uri; the scheme is inert in this DB-free contract test but tripping the B security rating on the PR. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzUpb2Dyunz2DCdwegR62Z --- backend/utils/tests/test_pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/utils/tests/test_pagination.py b/backend/utils/tests/test_pagination.py index 70fd5b0380..bcf1942be4 100644 --- a/backend/utils/tests/test_pagination.py +++ b/backend/utils/tests/test_pagination.py @@ -25,7 +25,7 @@ def __init__(self, params: dict[str, str]): self.query_params = params def build_absolute_uri(self) -> str: - return "http://testserver/things/" + return "https://testserver/things/" class TestOptionalPagination: From 845f2a76dc5e7ea23643e83bb7538e65c6179ab4 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 20 Jul 2026 21:14:48 +0530 Subject: [PATCH 3/5] UN-3770 [FIX] Address review: blank-param opt-in, stable ordering, empty-page fallback - OptionalPagination: treat blank ?page=/?page_size= as not opting in so shared callers keep their bare-array response (Greptile). - adapter/connector/prompt-studio get_queryset: order by the DISTINCT ON field for deterministic pagination, incl. the admin/service branch that has no distinct; not modified_at (conflicts with DISTINCT ON) (CodeRabbit). - Workflows.jsx: step back a page when a delete empties the current page (CodeRabbit). - Drop redundant "server-side name search" WHAT comments. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzUpb2Dyunz2DCdwegR62Z --- backend/adapter_processor_v2/views.py | 7 +++++-- backend/connector_v2/views.py | 6 ++++-- backend/prompt_studio/prompt_studio_core_v2/views.py | 5 +++-- backend/utils/pagination.py | 7 ++++--- backend/utils/tests/test_pagination.py | 5 +++++ backend/workflow_manager/workflow_v2/views.py | 1 - frontend/src/components/workflows/workflow/Workflows.jsx | 8 +++++++- 7 files changed, 28 insertions(+), 11 deletions(-) diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py index 8110fe27de..7dfacf4434 100644 --- a/backend/adapter_processor_v2/views.py +++ b/backend/adapter_processor_v2/views.py @@ -190,11 +190,14 @@ def get_queryset(self) -> QuerySet | None: ): queryset = queryset.filter(**filter_args) - # Server-side name search for the paginated listing page search = self.request.query_params.get("search") if search: queryset = queryset.filter(adapter_name__icontains=search) - return queryset + + # Order by the DISTINCT ON field so pagination is deterministic and the + # admin/service branch (no distinct) is ordered too. Not modified_at: + # that would conflict with the DISTINCT ON in for_user(). + return queryset.order_by("id") def get_serializer_class( self, diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py index c628e1b231..c4a3741f25 100644 --- a/backend/connector_v2/views.py +++ b/backend/connector_v2/views.py @@ -105,7 +105,6 @@ def get_queryset(self) -> QuerySet | None: if filter_args: queryset = queryset.filter(**filter_args) - # Server-side name search for the paginated listing page search = self.request.query_params.get("search") if search: queryset = queryset.filter(connector_name__icontains=search) @@ -128,7 +127,10 @@ def get_queryset(self) -> QuerySet | None: ) queryset = queryset.none() - return queryset + # Order by the DISTINCT ON field so pagination is deterministic and the + # admin/service branch (no distinct) is ordered too. Not modified_at: + # that would conflict with the DISTINCT ON in for_user(). + return queryset.order_by("id") def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None: """Gets connector metadata for the ConnectorInstance. diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py index 56706b35ed..c7251278ef 100644 --- a/backend/prompt_studio/prompt_studio_core_v2/views.py +++ b/backend/prompt_studio/prompt_studio_core_v2/views.py @@ -174,11 +174,12 @@ def get_queryset(self) -> QuerySet | None: qs = qs.select_related("created_by").annotate( _prompt_count=Subquery(prompt_count_sq), ) - # Server-side name search for the paginated listing page search = self.request.query_params.get("search") if search: qs = qs.filter(tool_name__icontains=search) - return qs + # Order by the DISTINCT ON field so pagination is deterministic and the + # admin/service branch (no distinct) is ordered too. + return qs.order_by("tool_id") def get_object(self): """Override get_object to trigger lazy migration when accessing tools.""" diff --git a/backend/utils/pagination.py b/backend/utils/pagination.py index 5b00781202..be1c6765c0 100644 --- a/backend/utils/pagination.py +++ b/backend/utils/pagination.py @@ -20,9 +20,10 @@ class OptionalPagination(CustomPagination): def paginate_queryset(self, queryset, request, view=None): params = request.query_params - if ( - self.page_query_param not in params - and self.page_size_query_param not in params + # Blank values (?page=) count as not opting in, so shared callers keep + # their bare-array response instead of getting an enveloped page. + if not params.get(self.page_query_param) and not params.get( + self.page_size_query_param ): return None return super().paginate_queryset(queryset, request, view=view) diff --git a/backend/utils/tests/test_pagination.py b/backend/utils/tests/test_pagination.py index bcf1942be4..44ab962171 100644 --- a/backend/utils/tests/test_pagination.py +++ b/backend/utils/tests/test_pagination.py @@ -37,6 +37,11 @@ def test_unrelated_params_do_not_trigger_pagination(self): req = _Req({"adapter_type": "LLM", "search": "foo"}) assert OptionalPagination().paginate_queryset(QUERYSET, req) is None + def test_blank_params_do_not_trigger_pagination(self): + """Empty ?page= / ?page_size= keep the bare-array response.""" + req = _Req({"page": "", "page_size": ""}) + assert OptionalPagination().paginate_queryset(QUERYSET, req) is None + def test_page_param_opts_in(self): paginator = OptionalPagination() page = paginator.paginate_queryset(QUERYSET, _Req({"page": "1"})) diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py index 6188c1fc6a..ef9f52f95f 100644 --- a/backend/workflow_manager/workflow_v2/views.py +++ b/backend/workflow_manager/workflow_v2/views.py @@ -115,7 +115,6 @@ def get_queryset(self) -> QuerySet: "memberships__user" ) - # Server-side name search for the paginated listing page search = self.request.query_params.get("search") if search: queryset = queryset.filter(workflow_name__icontains=search) diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index e1efee0ec3..e35d3da317 100644 --- a/frontend/src/components/workflows/workflow/Workflows.jsx +++ b/frontend/src/components/workflows/workflow/Workflows.jsx @@ -128,12 +128,18 @@ function Workflows() { // Endpoint is opt-in paginated: envelope when we send ?page, else a // bare array. Handle both so nothing breaks if the opt-in is dropped. const results = data?.results ?? data ?? []; + const total = data?.count ?? results.length; + // Deleting the last row on a page leaves it empty; step back a page. + if (results.length === 0 && page > 1 && total > 0) { + getProjectList(page - 1, pageSize, search); + return; + } setProjectList(results); setPagination((prev) => ({ ...prev, current: page, pageSize, - total: data?.count ?? results.length, + total, })); }) .catch(() => { From aecd00aa5bfe874f23484f4ab97ca9daa5d88f0b Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Mon, 20 Jul 2026 21:15:57 +0530 Subject: [PATCH 4/5] UN-3770 [FIX] Workflows: fix double refresh on edit and spinner on fetch failure - Drop the unconditional handleListRefresh() after edit; the branch already refreshes existing edits and navigates for new ones (CodeRabbit). - Default projectList to [] on fetch failure so the empty state shows instead of an indefinite spinner (CodeRabbit). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzUpb2Dyunz2DCdwegR62Z --- frontend/src/components/workflows/workflow/Workflows.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index e35d3da317..4aa5c23e0a 100644 --- a/frontend/src/components/workflows/workflow/Workflows.jsx +++ b/frontend/src/components/workflows/workflow/Workflows.jsx @@ -144,6 +144,8 @@ function Workflows() { }) .catch(() => { console.error("Unable to get project list"); + // Avoid an indefinite spinner when the first fetch fails. + setProjectList((prev) => prev ?? []); }) .finally(() => { setLoading(false); @@ -166,7 +168,6 @@ function Workflows() { type: "success", content: "Workflow updated successfully", }); - handleListRefresh(); }) .catch((err) => { setAlertDetails( From 1c777320154ab74b717fd66fabfb8e5a7a7b54b0 Mon Sep 17 00:00:00 2001 From: Chandrasekharan M Date: Tue, 21 Jul 2026 16:47:58 +0530 Subject: [PATCH 5/5] UN-3770 [FIX] Lock blank-pagination-param contract; align default page size Cover the mixed blank/non-blank pagination params. A blank ?page= alongside a real ?page_size= serves the first page rather than raising NotFound, since DRF resolves the page number as `query_params.get("page") or 1`. Also drop the Workflows page size from 12 to 10 to match the other paginated listing pages, which use the usePaginatedList default. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XzUpb2Dyunz2DCdwegR62Z --- backend/utils/tests/test_pagination.py | 21 +++++++++++++++++++ .../workflows/workflow/Workflows.jsx | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/utils/tests/test_pagination.py b/backend/utils/tests/test_pagination.py index 44ab962171..5bab488155 100644 --- a/backend/utils/tests/test_pagination.py +++ b/backend/utils/tests/test_pagination.py @@ -42,6 +42,27 @@ def test_blank_params_do_not_trigger_pagination(self): req = _Req({"page": "", "page_size": ""}) assert OptionalPagination().paginate_queryset(QUERYSET, req) is None + def test_single_blank_param_does_not_trigger_pagination(self): + for params in ({"page": ""}, {"page_size": ""}): + assert OptionalPagination().paginate_queryset(QUERYSET, _Req(params)) is None + + def test_blank_page_with_explicit_page_size_serves_first_page(self): + """A blank ?page= alongside a real ?page_size= is page 1, not an error. + + DRF reads the page number as `query_params.get("page") or 1`, so a blank + value falls back to the first page instead of raising NotFound. + """ + page = OptionalPagination().paginate_queryset( + QUERYSET, _Req({"page": "", "page_size": "10"}) + ) + assert page == list(range(1, 11)) + + def test_blank_page_size_with_explicit_page_uses_default_size(self): + page = OptionalPagination().paginate_queryset( + QUERYSET, _Req({"page": "2", "page_size": ""}) + ) + assert page == list(range(51, 101)) + def test_page_param_opts_in(self): paginator = OptionalPagination() page = paginator.paginate_queryset(QUERYSET, _Req({"page": "1"})) diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx index 4aa5c23e0a..7d5567c36f 100644 --- a/frontend/src/components/workflows/workflow/Workflows.jsx +++ b/frontend/src/components/workflows/workflow/Workflows.jsx @@ -32,7 +32,7 @@ import "./Workflows.css"; const { Text } = Typography; -const DEFAULT_PAGE_SIZE = 12; +const DEFAULT_PAGE_SIZE = 10; function Workflows() { const navigate = useNavigate();