diff --git a/backend/adapter_processor_v2/migrations/0006_adapterinstance_adapter_org_modified_idx.py b/backend/adapter_processor_v2/migrations/0006_adapterinstance_adapter_org_modified_idx.py
new file mode 100644
index 0000000000..a9e3f7df1e
--- /dev/null
+++ b/backend/adapter_processor_v2/migrations/0006_adapterinstance_adapter_org_modified_idx.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-07-30 09:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("adapter_processor_v2", "0005_absorb_shared_users"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="adapterinstance",
+ index=models.Index(
+ fields=["organization", "-modified_at"], name="adapter_org_modified_idx"
+ ),
+ ),
+ ]
diff --git a/backend/adapter_processor_v2/models.py b/backend/adapter_processor_v2/models.py
index adba66dbdb..a2287aa4a3 100644
--- a/backend/adapter_processor_v2/models.py
+++ b/backend/adapter_processor_v2/models.py
@@ -51,15 +51,11 @@ def for_user(self, user: User) -> QuerySet[Any]:
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)
- return (
- self.get_queryset()
- .filter(
- models.Q(pk__in=member_ids)
- | models.Q(shared_to_org=True)
- | models.Q(is_friction_less=True)
- | models.Q(pk__in=group_shared_ids)
- )
- .distinct("id")
+ return self.get_queryset().filter(
+ models.Q(pk__in=member_ids)
+ | models.Q(shared_to_org=True)
+ | models.Q(is_friction_less=True)
+ | models.Q(pk__in=group_shared_ids)
)
@@ -172,6 +168,13 @@ class Meta:
name="unique_organization_adapter",
),
]
+ # Backs the default org-scoped `-modified_at, pk` list ordering.
+ indexes = [
+ models.Index(
+ fields=["organization", "-modified_at"],
+ name="adapter_org_modified_idx",
+ ),
+ ]
def create_adapter(self) -> None:
encryption_secret: str = settings.ENCRYPTION_KEY
diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py
index a5f2c492d6..c8545223bd 100644
--- a/backend/adapter_processor_v2/serializers.py
+++ b/backend/adapter_processor_v2/serializers.py
@@ -207,10 +207,14 @@ def to_representation(self, instance: AdapterInstance) -> dict[str, str]:
if model:
rep["model"] = model
+ # Frictionless (Unstract-provisioned) adapters mask the owner org-wide;
+ # mask owner_emails too, else the Owned By column leaks the real owner.
if instance.is_friction_less:
rep["created_by_email"] = "Unstract"
+ rep["owner_emails"] = ["Unstract"]
else:
rep["created_by_email"] = instance.created_by.email
+ rep["owner_emails"] = instance.owner_emails()
request = self.context.get("request")
rep["is_owner"] = instance.is_owner(request.user) if request else False
diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py
index 7dfacf4434..f2aef82b0c 100644
--- a/backend/adapter_processor_v2/views.py
+++ b/backend/adapter_processor_v2/views.py
@@ -148,6 +148,9 @@ class AdapterInstanceViewSet(
):
serializer_class = AdapterInstanceSerializer
pagination_class = OptionalPagination
+ # `pk` tiebreaker keeps paging deterministic when modified_at collides.
+ ordering = ["-modified_at", "pk"]
+ ordering_fields = ["adapter_name", "created_at", "modified_at"]
notification_resource_name_field = "adapter_name"
def get_notification_resource_type(self, resource: Any) -> str | None:
@@ -192,12 +195,17 @@ def get_queryset(self) -> QuerySet | None:
search = self.request.query_params.get("search")
if search:
- queryset = queryset.filter(adapter_name__icontains=search)
+ from django.db.models import Q
+ from tenant_account_v2.sharing_helpers import (
+ resources_matching_owner_search,
+ )
+
+ queryset = queryset.filter(
+ Q(adapter_name__icontains=search)
+ | Q(pk__in=resources_matching_owner_search(queryset.model, search))
+ )
- # 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")
+ return queryset
def get_serializer_class(
self,
diff --git a/backend/backend/settings/base.py b/backend/backend/settings/base.py
index 94071ac3b8..179e62593a 100644
--- a/backend/backend/settings/base.py
+++ b/backend/backend/settings/base.py
@@ -627,7 +627,7 @@ def filter(self, record):
"DEFAULT_FILTER_BACKENDS": [
"utils.filters.organization_filter.OrganizationFilterBackend",
"django_filters.rest_framework.DjangoFilterBackend",
- "rest_framework.filters.OrderingFilter",
+ "utils.filters.ordering_filter.DeterministicOrderingFilter",
],
# For API versioning
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
diff --git a/backend/connector_v2/migrations/0008_connectorinstance_connector_org_modified_idx.py b/backend/connector_v2/migrations/0008_connectorinstance_connector_org_modified_idx.py
new file mode 100644
index 0000000000..adf8efb4b0
--- /dev/null
+++ b/backend/connector_v2/migrations/0008_connectorinstance_connector_org_modified_idx.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-07-30 09:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("connector_v2", "0007_absorb_shared_users"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="connectorinstance",
+ index=models.Index(
+ fields=["organization", "-modified_at"], name="connector_org_modified_idx"
+ ),
+ ),
+ ]
diff --git a/backend/connector_v2/models.py b/backend/connector_v2/models.py
index 499374d7ad..e0790c052c 100644
--- a/backend/connector_v2/models.py
+++ b/backend/connector_v2/models.py
@@ -44,14 +44,10 @@ def for_user(self, user: User) -> models.QuerySet:
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)
- return (
- self.get_queryset()
- .filter(
- models.Q(pk__in=member_ids)
- | models.Q(shared_to_org=True)
- | models.Q(pk__in=group_shared_ids)
- )
- .distinct("id")
+ return self.get_queryset().filter(
+ models.Q(pk__in=member_ids)
+ | models.Q(shared_to_org=True)
+ | models.Q(pk__in=group_shared_ids)
)
@@ -173,3 +169,10 @@ class Meta:
name="unique_organization_connector",
),
]
+ # Backs the default org-scoped `-modified_at, pk` list ordering.
+ indexes = [
+ models.Index(
+ fields=["organization", "-modified_at"],
+ name="connector_org_modified_idx",
+ ),
+ ]
diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py
index 5c4c158333..8e5583e889 100644
--- a/backend/connector_v2/serializers.py
+++ b/backend/connector_v2/serializers.py
@@ -176,6 +176,7 @@ def to_representation(self, instance: ConnectorInstance) -> dict[str, str]:
request = self.context.get("request")
rep["is_owner"] = instance.is_owner(request.user) if request else False
rep["co_owners_count"] = instance.co_owners_count()
+ rep["owner_emails"] = instance.owner_emails()
return rep
diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py
index c4a3741f25..fd75b749db 100644
--- a/backend/connector_v2/views.py
+++ b/backend/connector_v2/views.py
@@ -47,6 +47,9 @@ class ConnectorInstanceViewSet(
versioning_class = URLPathVersioning
serializer_class = ConnectorInstanceSerializer
pagination_class = OptionalPagination
+ # `pk` tiebreaker keeps paging deterministic when modified_at collides.
+ ordering = ["-modified_at", "pk"]
+ ordering_fields = ["connector_name", "created_at", "modified_at"]
notification_resource_name_field = "connector_name"
def get_notification_resource_type(self, resource: Any) -> str | None:
@@ -105,10 +108,6 @@ def get_queryset(self) -> QuerySet | None:
if filter_args:
queryset = queryset.filter(**filter_args)
- 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:
@@ -127,10 +126,19 @@ def get_queryset(self) -> QuerySet | None:
)
queryset = queryset.none()
- # 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")
+ search = self.request.query_params.get("search")
+ if search:
+ from django.db.models import Q
+ from tenant_account_v2.sharing_helpers import (
+ resources_matching_owner_search,
+ )
+
+ queryset = queryset.filter(
+ Q(connector_name__icontains=search)
+ | Q(pk__in=resources_matching_owner_search(queryset.model, search))
+ )
+
+ return queryset
def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None:
"""Gets connector metadata for the ConnectorInstance.
diff --git a/backend/dashboard_metrics/views.py b/backend/dashboard_metrics/views.py
index 394b34d5b9..4c59d1536c 100644
--- a/backend/dashboard_metrics/views.py
+++ b/backend/dashboard_metrics/views.py
@@ -14,11 +14,11 @@
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
-from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
+from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.user_context import UserContext
from .cache import (
@@ -156,7 +156,7 @@ class DashboardMetricsViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated, IsOrganizationMember]
throttle_classes = [MetricsRateThrottle]
serializer_class = EventMetricsHourlySerializer
- filter_backends = [DjangoFilterBackend, OrderingFilter]
+ filter_backends = [DjangoFilterBackend, DeterministicOrderingFilter]
ordering_fields = ["timestamp", "metric_name", "metric_value"]
ordering = ["-timestamp"]
diff --git a/backend/permissions/models.py b/backend/permissions/models.py
index 9c63804e16..7f5ad12087 100644
--- a/backend/permissions/models.py
+++ b/backend/permissions/models.py
@@ -43,6 +43,32 @@ def co_owners_count(self) -> int:
for m in self.memberships.all() # type: ignore[attr-defined]
)
+ def owner_email(self) -> str | None:
+ # "Owned By" email: earliest live OWNER. ``created_by`` is audit-only
+ # (UN-2202) and may differ from the owner. Reads prefetched
+ # ``memberships`` to stay query-free.
+ owners = [
+ m
+ for m in self.memberships.all() # type: ignore[attr-defined]
+ if m.role == ResourceRole.OWNER and not m.user.is_service_account
+ ]
+ if not owners:
+ return None
+ # pk breaks created_at ties so the label is stable across requests.
+ return min(owners, key=lambda m: (m.created_at, m.pk)).user.email
+
+ def owner_emails(self) -> list[str]:
+ # Every live OWNER email, earliest-first (``owner_email`` is just the
+ # head). Backs the Owned By tooltip so all co-owners are named, not only
+ # the primary + a ``+N`` count. Reads prefetched ``memberships``.
+ owners = [
+ m
+ for m in self.memberships.all() # type: ignore[attr-defined]
+ if m.role == ResourceRole.OWNER and not m.user.is_service_account
+ ]
+ owners.sort(key=lambda m: (m.created_at, m.pk))
+ return [m.user.email for m in owners]
+
def is_owner(self, user: Any) -> bool:
if user is None:
return False
diff --git a/backend/pipeline_v2/views.py b/backend/pipeline_v2/views.py
index 41024dd409..ec7e7720f3 100644
--- a/backend/pipeline_v2/views.py
+++ b/backend/pipeline_v2/views.py
@@ -16,11 +16,11 @@
from plugins import get_plugin
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
-from rest_framework.filters import OrderingFilter
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.versioning import URLPathVersioning
from scheduler.helper import SchedulerHelper
+from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.pagination import CustomPagination
from pipeline_v2.constants import (
@@ -51,7 +51,7 @@ class PipelineViewSet(
versioning_class = URLPathVersioning
queryset = Pipeline.objects.all()
pagination_class = CustomPagination
- filter_backends = [OrderingFilter]
+ filter_backends = [DeterministicOrderingFilter]
ordering_fields = ["created_at", "last_run_time", "pipeline_name", "run_count"]
# Note: Default ordering with nulls_last is applied in get_queryset()
# DRF's ordering attribute doesn't support nulls_last natively
diff --git a/backend/prompt_studio/prompt_studio_core_v2/migrations/0010_customtool_custtool_org_modified_idx.py b/backend/prompt_studio/prompt_studio_core_v2/migrations/0010_customtool_custtool_org_modified_idx.py
new file mode 100644
index 0000000000..363cb2ef3e
--- /dev/null
+++ b/backend/prompt_studio/prompt_studio_core_v2/migrations/0010_customtool_custtool_org_modified_idx.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-07-30 09:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("prompt_studio_core_v2", "0009_absorb_shared_users"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="customtool",
+ index=models.Index(
+ fields=["organization", "-modified_at"], name="custtool_org_modified_idx"
+ ),
+ ),
+ ]
diff --git a/backend/prompt_studio/prompt_studio_core_v2/models.py b/backend/prompt_studio/prompt_studio_core_v2/models.py
index f09a468546..4403de75db 100644
--- a/backend/prompt_studio/prompt_studio_core_v2/models.py
+++ b/backend/prompt_studio/prompt_studio_core_v2/models.py
@@ -40,14 +40,10 @@ def for_user(self, user: User) -> QuerySet[Any]:
group_shared_ids = resources_visible_via_groups(self.model, user_group_ids)
member_ids = resources_visible_via_memberships(self.model, user)
- return (
- self.get_queryset()
- .filter(
- models.Q(pk__in=member_ids)
- | models.Q(shared_to_org=True)
- | models.Q(pk__in=group_shared_ids)
- )
- .distinct("tool_id")
+ return self.get_queryset().filter(
+ models.Q(pk__in=member_ids)
+ | models.Q(shared_to_org=True)
+ | models.Q(pk__in=group_shared_ids)
)
@@ -226,3 +222,10 @@ class Meta:
name="unique_tool_name",
),
]
+ # Backs the default org-scoped `-modified_at, pk` list ordering.
+ indexes = [
+ models.Index(
+ fields=["organization", "-modified_at"],
+ name="custtool_org_modified_idx",
+ ),
+ ]
diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
index 245f2c0743..acb3a243d7 100644
--- a/backend/prompt_studio/prompt_studio_core_v2/serializers.py
+++ b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
@@ -48,6 +48,7 @@ class CustomToolListSerializer(serializers.ModelSerializer):
prompt_count = serializers.SerializerMethodField()
is_owner = serializers.SerializerMethodField()
co_owners_count = serializers.SerializerMethodField()
+ owner_emails = serializers.SerializerMethodField()
class Meta:
model = CustomTool
@@ -65,6 +66,7 @@ class Meta:
"prompt_count",
"is_owner",
"co_owners_count",
+ "owner_emails",
]
def get_created_by_email(self, instance):
@@ -77,6 +79,9 @@ def get_is_owner(self, instance) -> bool:
def get_co_owners_count(self, instance) -> int:
return instance.co_owners_count()
+ def get_owner_emails(self, instance) -> list[str]:
+ return instance.owner_emails()
+
def get_prompt_count(self, instance):
if hasattr(instance, "_prompt_count"):
return instance._prompt_count or 0
diff --git a/backend/prompt_studio/prompt_studio_core_v2/views.py b/backend/prompt_studio/prompt_studio_core_v2/views.py
index ae11da451a..cd65f2de77 100644
--- a/backend/prompt_studio/prompt_studio_core_v2/views.py
+++ b/backend/prompt_studio/prompt_studio_core_v2/views.py
@@ -133,6 +133,9 @@ class PromptStudioCoreView(
versioning_class = URLPathVersioning
pagination_class = OptionalPagination
+ # `pk` tiebreaker keeps paging deterministic when modified_at collides.
+ ordering = ["-modified_at", "pk"]
+ ordering_fields = ["tool_name", "created_at", "modified_at"]
serializer_class = CustomToolSerializer
notification_resource_name_field = "tool_name"
@@ -161,7 +164,8 @@ def get_queryset(self) -> QuerySet | None:
"memberships__user"
)
if self.action == "list":
- # Subquery avoids conflict with distinct("tool_id") from for_user()
+ # Subquery rather than a join-aggregate: Count() over a join would
+ # need a GROUP BY that fights the queryset's distinct()
prompt_count_sq = (
ToolStudioPrompt.objects.filter(tool_id=OuterRef("pk"))
.order_by()
@@ -169,20 +173,23 @@ def get_queryset(self) -> QuerySet | None:
.annotate(cnt=Count("prompt_id"))
.values("cnt")
)
- # modified_at needs no annotation: prompt writes bump the parent
- # row at the source (ToolStudioPrompt.save/delete, sync_prompts),
- # keeping the plain field orderable. Only prompt writes bump —
- # profile/document edits and queryset-level prompt writes do not;
- # any new write path must bump CustomTool itself
+ # modified_at stays a plain orderable field: prompt writes bump the
+ # parent row, so no annotation is needed.
qs = qs.select_related("created_by").annotate(
_prompt_count=Subquery(prompt_count_sq),
)
search = self.request.query_params.get("search")
if search:
- qs = qs.filter(tool_name__icontains=search)
- # 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")
+ from django.db.models import Q
+ from tenant_account_v2.sharing_helpers import (
+ resources_matching_owner_search,
+ )
+
+ qs = qs.filter(
+ Q(tool_name__icontains=search)
+ | Q(pk__in=resources_matching_owner_search(qs.model, search))
+ )
+ return qs
def get_object(self):
"""Override get_object to trigger lazy migration when accessing tools."""
@@ -943,8 +950,7 @@ def task_status(
# than a bare 500 that a status-code-keyed client would misread as a
# terminal task failure.
logger.exception(
- "task_status: pg_task_result read failed for %s; treating as "
- "pending",
+ "task_status: pg_task_result read failed for %s; treating as pending",
task_id,
)
row = None
diff --git a/backend/tags/views.py b/backend/tags/views.py
index d8fcff5a84..b7d3bf43b2 100644
--- a/backend/tags/views.py
+++ b/backend/tags/views.py
@@ -2,9 +2,9 @@
from permissions.permission import IsOrganizationMember
from rest_framework import status, viewsets
from rest_framework.decorators import action
-from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
+from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.pagination import CustomPagination
from workflow_manager.file_execution.serializers import WorkflowFileExecutionSerializer
from workflow_manager.workflow_v2.serializers import WorkflowExecutionSerializer
@@ -19,7 +19,7 @@ class TagViewSet(viewsets.ModelViewSet):
serializer_class = TagSerializer
pagination_class = CustomPagination
ordering_fields = ["created_at"]
- filter_backends = [DjangoFilterBackend, OrderingFilter]
+ filter_backends = [DjangoFilterBackend, DeterministicOrderingFilter]
filterset_fields = ["name"]
def get_queryset(self):
diff --git a/backend/tenant_account_v2/sharing_helpers.py b/backend/tenant_account_v2/sharing_helpers.py
index 7420af0cfd..36887b1331 100644
--- a/backend/tenant_account_v2/sharing_helpers.py
+++ b/backend/tenant_account_v2/sharing_helpers.py
@@ -36,6 +36,7 @@
from django.db import models, transaction
from django.db.models import Model, QuerySet
from django.db.models.functions import Cast
+from permissions.roles import ResourceRole
from rest_framework.exceptions import ValidationError
from utils.user_context import UserContext
@@ -221,6 +222,30 @@ def resources_visible_via_memberships(
return _object_id_subquery(qs, model)
+def resources_matching_owner_search(
+ model: type[Model], term: str, organization: Organization | None = None
+) -> QuerySet[Any]:
+ """Subquery of ``model`` PKs whose displayed owner matches ``term``.
+
+ Owner search hits the same OWNER memberships that back the "Owned By"
+ column, so the search box agrees with what is shown. Matches on the email
+ prefix (the local part is the shown name) so a bare domain fragment like
+ "com" doesn't return every row in a single-domain org. Skips service
+ accounts, org-scoped like :func:`resources_visible_via_memberships`. Any
+ OWNER counts, so a co-owner's email surfaces the resource too.
+ """
+ organization = organization or UserContext.get_organization()
+ qs = ResourceMembership.objects.filter(
+ content_type=ContentType.objects.get_for_model(model),
+ role=ResourceRole.OWNER,
+ user__is_service_account=False,
+ user__email__istartswith=term,
+ )
+ if organization is not None:
+ qs = qs.filter(organization=organization)
+ return _object_id_subquery(qs, model)
+
+
def serialize_group_refs(resource_obj: Any) -> list[dict[str, Any]]:
"""Return a compact ``[{id, name}]`` listing for share modals."""
return list(get_resource_share_groups(resource_obj).values("id", "name"))
diff --git a/backend/usage_v2/views.py b/backend/usage_v2/views.py
index 414814d80b..f374e0197f 100644
--- a/backend/usage_v2/views.py
+++ b/backend/usage_v2/views.py
@@ -6,10 +6,10 @@
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
-from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from utils.date import DateTimeProcessor
+from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.pagination import CustomPagination
from utils.user_context import UserContext
@@ -29,7 +29,7 @@ class UsageView(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsOrganizationMember]
serializer_class = UsageSerializer
pagination_class = CustomPagination
- filter_backends = [DjangoFilterBackend, OrderingFilter]
+ filter_backends = [DjangoFilterBackend, DeterministicOrderingFilter]
filterset_class = UsageFilter
ordering_fields = ["created_at"]
diff --git a/backend/utils/filters/ordering_filter.py b/backend/utils/filters/ordering_filter.py
new file mode 100644
index 0000000000..82a7e6e40c
--- /dev/null
+++ b/backend/utils/filters/ordering_filter.py
@@ -0,0 +1,22 @@
+from rest_framework.filters import OrderingFilter
+
+
+class DeterministicOrderingFilter(OrderingFilter):
+ """OrderingFilter that always ends the ordering with the primary key.
+
+ Pagination runs one query per page, so an ordering with ties leaves the
+ tied rows in whatever order the DB returns that time — a row can repeat on
+ the next page or be skipped entirely. A trailing unique column removes the
+ ambiguity. `?ordering=` replaces the view's `ordering`, so the tie-breaker
+ has to be appended here rather than declared on the view.
+ """
+
+ def get_ordering(self, request, queryset, view):
+ ordering = super().get_ordering(request, queryset, view)
+ if not ordering:
+ return ordering
+
+ pk_names = {"pk", queryset.model._meta.pk.name}
+ if any(term.lstrip("-") in pk_names for term in ordering):
+ return ordering
+ return [*ordering, "pk"]
diff --git a/backend/utils/tests/test_list_pagination.py b/backend/utils/tests/test_list_pagination.py
new file mode 100644
index 0000000000..d68a3e6abc
--- /dev/null
+++ b/backend/utils/tests/test_list_pagination.py
@@ -0,0 +1,334 @@
+"""Pagination contract for the four shared list endpoints (UN-3770).
+
+Workflows, Prompt Studio, adapters and connectors share one listing shape:
+``for_user()`` sharing predicate -> ``.distinct()`` -> declarative ``ordering``
+-> ``OptionalPagination``. These tests pin the ways that combination can
+silently serve wrong rows — non-deterministic page boundaries, a client
+``?ordering=`` that drops the pk tie-breaker, duplicate rows from the sharing
+predicate, and a search applied after the count.
+
+DB-backed (Django ``TestCase``), so ``backend/conftest.py`` auto-marks these
+``integration``.
+"""
+
+from __future__ import annotations
+
+import json
+from collections.abc import Callable
+from datetime import UTC, datetime, timedelta
+from functools import lru_cache
+from typing import Any, NamedTuple
+
+from adapter_processor_v2.views import AdapterInstanceViewSet
+from connector_v2.views import ConnectorInstanceViewSet
+from django.test import TestCase
+from permissions.roles import ResourceRole
+from permissions.tests.base import (
+ CoOwnerOrgTestMixin,
+ _build_adapter,
+ _build_connector,
+ _build_custom_tool,
+ _build_workflow,
+ make_user,
+)
+from prompt_studio.prompt_studio_core_v2.views import PromptStudioCoreView
+from rest_framework import status
+from rest_framework.test import APIRequestFactory, force_authenticate
+from workflow_manager.workflow_v2.views import WorkflowViewSet
+
+# Fixed so ordering assertions never depend on wall-clock ties.
+BASE_TIME = datetime(2026, 1, 1, tzinfo=UTC)
+
+
+@lru_cache(maxsize=1)
+def _live_llm_adapter_id() -> str:
+ from unstract.sdk1.adapters.adapterkit import Adapterkit
+
+ return next(
+ a["id"] for a in Adapterkit().get_adapters_list() if a["adapter_type"] == "LLM"
+ )
+
+
+@lru_cache(maxsize=1)
+def _live_connector_id() -> str:
+ from unstract.connectors.connectorkit import Connectorkit
+
+ return Connectorkit().get_connectors_list()[0]["id"]
+
+
+def _prepare_adapter(obj: Any) -> None:
+ """Make the row serialisable: real registry id, real encrypted metadata."""
+ from cryptography.fernet import Fernet
+ from django.conf import settings
+
+ obj.adapter_id = _live_llm_adapter_id()
+ fernet = Fernet(settings.ENCRYPTION_KEY.encode("utf-8"))
+ obj.adapter_metadata_b = fernet.encrypt(
+ json.dumps({"model": "gpt-4o-mini"}).encode("utf-8")
+ )
+
+
+def _prepare_connector(obj: Any) -> None:
+ obj.connector_id = _live_connector_id()
+
+
+class ListEndpoint(NamedTuple):
+ kind: str
+ viewset: Any
+ build: Any
+ name_field: str
+ # Adapter and connector rows are serialised against the live registry, so
+ # the shared builders' placeholder ids error before paging is reached.
+ prepare: Callable[[Any], None] | None = None
+
+
+LIST_ENDPOINTS = [
+ ListEndpoint("workflow", WorkflowViewSet, _build_workflow, "workflow_name"),
+ ListEndpoint("custom_tool", PromptStudioCoreView, _build_custom_tool, "tool_name"),
+ ListEndpoint(
+ "adapter",
+ AdapterInstanceViewSet,
+ _build_adapter,
+ "adapter_name",
+ prepare=_prepare_adapter,
+ ),
+ ListEndpoint(
+ "connector",
+ ConnectorInstanceViewSet,
+ _build_connector,
+ "connector_name",
+ prepare=_prepare_connector,
+ ),
+]
+
+
+class ListPaginationContractTests(CoOwnerOrgTestMixin, TestCase):
+ def setUp(self) -> None:
+ self._seed_org()
+ self.factory = APIRequestFactory()
+
+ def _create(self, endpoint: ListEndpoint, name: str, owner=None) -> Any:
+ """A resource named ``name``, owned (membership) by ``owner``."""
+ owner = owner or self.owner
+ obj = endpoint.build(self.org, owner)
+ setattr(obj, endpoint.name_field, name)
+ if endpoint.prepare:
+ endpoint.prepare(obj)
+ obj.save()
+ obj.memberships.create(user=owner, role=ResourceRole.OWNER)
+ return obj
+
+ def _list(self, endpoint: ListEndpoint, user, **params: Any):
+ view = endpoint.viewset.as_view({"get": "list"})
+ request = self.factory.get("/x/", params)
+ force_authenticate(request, user=user)
+ response = view(request)
+ assert response.status_code == status.HTTP_200_OK, response.data
+ return response
+
+ def _names(self, endpoint: ListEndpoint, rows) -> list[str]:
+ return [row[endpoint.name_field] for row in rows]
+
+ def _stamp(self, obj: Any, when: datetime) -> None:
+ """Pin ``modified_at``, which ``auto_now`` would otherwise overwrite."""
+ type(obj).objects.filter(pk=obj.pk).update(modified_at=when)
+
+ def test_pages_partition_the_result_set_newest_first(self) -> None:
+ """Pages follow the declared ``-modified_at`` and together cover the set.
+
+ Asserting the sequence, not just membership: page boundaries are only
+ stable if every request evaluates the same ordering, so a lost or
+ arbitrary ordering has to fail here rather than pass on set equality.
+ """
+ for endpoint in LIST_ENDPOINTS:
+ with self.subTest(kind=endpoint.kind):
+ # Created oldest-first, so the expected listing is the reverse.
+ for i in range(5):
+ obj = self._create(endpoint, f"{endpoint.kind}-page-{i}")
+ self._stamp(obj, BASE_TIME + timedelta(minutes=i))
+ expected = [f"{endpoint.kind}-page-{i}" for i in reversed(range(5))]
+
+ pages = [
+ self._list(endpoint, self.owner, page=n, page_size=2)
+ for n in (1, 2, 3)
+ ]
+ names = [
+ n
+ for page in pages
+ for n in self._names(endpoint, page.data["results"])
+ ]
+
+ assert pages[0].data["count"] == len(expected)
+ assert names == expected
+
+ def test_client_ordering_keeps_pk_tiebreaker(self) -> None:
+ """``?ordering=`` replaces the view default, so pk must still be appended.
+
+ Two ``modified_at`` groups with ties in each: an ascending client order
+ must list the older group first — distinguishing it from the
+ ``-modified_at`` default — and break each in-group tie by pk. Primary
+ keys are random UUIDs unrelated to insertion order, so the sequence only
+ matches if pk survived as the tie-breaker.
+ """
+ for endpoint in LIST_ENDPOINTS:
+ with self.subTest(kind=endpoint.kind):
+ created = []
+ for group in range(2):
+ for i in range(3):
+ obj = self._create(endpoint, f"{endpoint.kind}-g{group}-{i}")
+ self._stamp(obj, BASE_TIME + timedelta(minutes=group))
+ created.append((group, obj))
+ expected = [
+ getattr(obj, endpoint.name_field)
+ for _, obj in sorted(created, key=lambda t: (t[0], str(t[1].pk)))
+ ]
+
+ pages = [
+ self._list(
+ endpoint,
+ self.owner,
+ page=n,
+ page_size=3,
+ ordering="modified_at",
+ )
+ for n in (1, 2)
+ ]
+ names = [
+ n
+ for page in pages
+ for n in self._names(endpoint, page.data["results"])
+ ]
+
+ assert names == expected
+
+ def test_multi_predicate_share_yields_one_row(self) -> None:
+ """A resource reachable by several sharing predicates lists once.
+
+ ``for_user()`` ORs membership / org-share / group-share together; if any
+ arm ever becomes a join instead of a PK subquery, the row duplicates and
+ ``count`` overstates. Dedup is what ``.distinct()`` is there for.
+ """
+ for endpoint in LIST_ENDPOINTS:
+ with self.subTest(kind=endpoint.kind):
+ name = f"{endpoint.kind}-multi-share"
+ obj = self._create(endpoint, name)
+ # Reachable via membership AND org-share simultaneously.
+ obj.memberships.create(user=self.viewer, role=ResourceRole.VIEWER)
+ obj.shared_to_org = True
+ obj.save()
+
+ response = self._list(endpoint, self.viewer, page=1, page_size=10)
+
+ assert self._names(endpoint, response.data["results"]).count(name) == 1
+ assert response.data["count"] == 1
+
+ def test_search_narrows_rows_and_count(self) -> None:
+ """``?search=`` must filter before the count, not after paging."""
+ for endpoint in LIST_ENDPOINTS:
+ with self.subTest(kind=endpoint.kind):
+ for i in range(3):
+ self._create(endpoint, f"{endpoint.kind}-alpha-{i}")
+ for i in range(2):
+ self._create(endpoint, f"{endpoint.kind}-beta-{i}")
+
+ response = self._list(
+ endpoint, self.owner, page=1, page_size=10, search="alpha"
+ )
+
+ names = self._names(endpoint, response.data["results"])
+ assert response.data["count"] == 3
+ assert all("alpha" in name for name in names)
+
+ def test_search_matches_name_and_owner_email(self) -> None:
+ """``?search=`` matches the resource name or the displayed owner's email.
+
+ UN-3770 restored owner search, but against the OWNER membership that
+ backs the Owned By column — not the audit-only ``created_by`` (UN-2202).
+ A viewer's email must not surface the row; only owners match.
+ """
+ for endpoint in LIST_ENDPOINTS:
+ with self.subTest(kind=endpoint.kind):
+ obj = self._create(
+ endpoint, f"{endpoint.kind}-searchable", owner=self.owner
+ )
+ # Viewer shares the row but is not an owner.
+ obj.memberships.create(user=self.viewer, role=ResourceRole.VIEWER)
+
+ by_name = self._list(
+ endpoint, self.owner, page=1, page_size=10, search="searchable"
+ )
+ by_owner = self._list(
+ endpoint, self.owner, page=1, page_size=10, search="owner@example"
+ )
+ by_viewer = self._list(
+ endpoint, self.owner, page=1, page_size=10, search="viewer@example"
+ )
+
+ assert by_name.data["count"] == 1
+ assert by_owner.data["count"] == 1
+ assert by_viewer.data["count"] == 0
+
+ def test_dropped_owner_ordering_field_is_ignored(self) -> None:
+ """``?ordering=created_by__email`` is a dropped field, so it's ignored.
+
+ UN-3769 removed ``created_by__email`` from ``ordering_fields``. DRF drops
+ an unknown ordering key and falls back to the view default
+ (``-modified_at, pk``) rather than 400ing, so the rows stay newest-first.
+ Every row shares one creator, so had the field survived the response
+ would be pk-ordered, not the newest-first sequence asserted here.
+ """
+ for endpoint in LIST_ENDPOINTS:
+ with self.subTest(kind=endpoint.kind):
+ for i in range(5):
+ obj = self._create(endpoint, f"{endpoint.kind}-ord-{i}")
+ self._stamp(obj, BASE_TIME + timedelta(minutes=i))
+ expected = [f"{endpoint.kind}-ord-{i}" for i in reversed(range(5))]
+
+ response = self._list(
+ endpoint,
+ self.owner,
+ page=1,
+ page_size=10,
+ ordering="created_by__email",
+ )
+
+ assert self._names(endpoint, response.data["results"]) == expected
+
+ def test_owner_email_is_earliest_live_owner(self) -> None:
+ """``owner_email()`` (the Owned By label) names the earliest live OWNER,
+ skips service accounts, and is ``None`` with no owner. Shared-mixin
+ behaviour, so one endpoint pins it for all four.
+ """
+ svc = make_user("svc@example.com", is_service_account=True)
+ wf = _build_workflow(self.org, self.owner)
+ # Service account owns earliest (must be skipped); coowner then owns
+ # before owner, so coowner is the earliest live owner.
+ for user, minute in ((svc, 0), (self.coowner, 1), (self.owner, 2)):
+ membership = wf.memberships.create(user=user, role=ResourceRole.OWNER)
+ type(membership).objects.filter(pk=membership.pk).update(
+ created_at=BASE_TIME + timedelta(minutes=minute)
+ )
+
+ fresh = type(wf).objects.get(pk=wf.pk)
+ assert fresh.owner_email() == self.coowner.email
+ # owner_emails() is the full roster, earliest-first, svc still skipped.
+ assert fresh.owner_emails() == [self.coowner.email, self.owner.email]
+
+ # Tied created_at: pk decides, so the label never flips between requests.
+ wf.memberships.all().delete()
+ tied = [
+ wf.memberships.create(user=user, role=ResourceRole.OWNER)
+ for user in (self.owner, self.coowner)
+ ]
+ type(tied[0]).objects.filter(pk__in=[m.pk for m in tied]).update(
+ created_at=BASE_TIME
+ )
+ ordered = sorted(tied, key=lambda m: m.pk)
+ fresh = type(wf).objects.get(pk=wf.pk)
+ assert fresh.owner_email() == ordered[0].user.email
+ assert fresh.owner_emails() == [m.user.email for m in ordered]
+
+ wf.memberships.all().delete()
+ fresh = type(wf).objects.get(pk=wf.pk)
+ assert fresh.owner_email() is None
+ assert fresh.owner_emails() == []
diff --git a/backend/workflow_manager/execution/views/execution.py b/backend/workflow_manager/execution/views/execution.py
index 5363ef5082..a80a1e354f 100644
--- a/backend/workflow_manager/execution/views/execution.py
+++ b/backend/workflow_manager/execution/views/execution.py
@@ -2,8 +2,8 @@
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
-from rest_framework.filters import OrderingFilter
from rest_framework.permissions import BasePermission, IsAuthenticated
+from utils.filters.ordering_filter import DeterministicOrderingFilter
from utils.pagination import CustomPagination
from workflow_manager.execution.filter import ExecutionFilter
@@ -34,7 +34,7 @@ class ExecutionViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = [IsAuthenticated, UserWorkflowExecutionPermission]
serializer_class = ExecutionSerializer
pagination_class = CustomPagination
- filter_backends = [DjangoFilterBackend, OrderingFilter]
+ filter_backends = [DjangoFilterBackend, DeterministicOrderingFilter]
ordering_fields = ["created_at", "execution_time"]
ordering = ["-created_at"]
filterset_class = ExecutionFilter
diff --git a/backend/workflow_manager/workflow_v2/migrations/0025_workflow_workflow_org_modified_idx.py b/backend/workflow_manager/workflow_v2/migrations/0025_workflow_workflow_org_modified_idx.py
new file mode 100644
index 0000000000..af4150024f
--- /dev/null
+++ b/backend/workflow_manager/workflow_v2/migrations/0025_workflow_workflow_org_modified_idx.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-07-30 09:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("workflow_v2", "0024_merge_coowner_and_active_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="workflow",
+ index=models.Index(
+ fields=["organization", "-modified_at"], name="workflow_org_modified_idx"
+ ),
+ ),
+ ]
diff --git a/backend/workflow_manager/workflow_v2/models/workflow.py b/backend/workflow_manager/workflow_v2/models/workflow.py
index 461e63689b..0fd5039603 100644
--- a/backend/workflow_manager/workflow_v2/models/workflow.py
+++ b/backend/workflow_manager/workflow_v2/models/workflow.py
@@ -159,3 +159,10 @@ class Meta:
name="unique_workflow_name",
),
]
+ # Backs the default org-scoped `-modified_at, pk` list ordering.
+ indexes = [
+ models.Index(
+ fields=["organization", "-modified_at"],
+ name="workflow_org_modified_idx",
+ ),
+ ]
diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py
index c9715a9acb..f96c3bd70a 100644
--- a/backend/workflow_manager/workflow_v2/serializers.py
+++ b/backend/workflow_manager/workflow_v2/serializers.py
@@ -85,6 +85,7 @@ def to_representation(self, instance: Workflow) -> dict[str, str]:
request = self.context.get("request")
representation["is_owner"] = instance.is_owner(request.user) if request else False
representation["co_owners_count"] = instance.co_owners_count()
+ representation["owner_emails"] = instance.owner_emails()
return representation
def create(self, validated_data: dict[str, Any]) -> Any:
diff --git a/backend/workflow_manager/workflow_v2/views.py b/backend/workflow_manager/workflow_v2/views.py
index ef9f52f95f..fefba8c21a 100644
--- a/backend/workflow_manager/workflow_v2/views.py
+++ b/backend/workflow_manager/workflow_v2/views.py
@@ -77,6 +77,9 @@ class WorkflowViewSet(
):
versioning_class = URLPathVersioning
pagination_class = OptionalPagination
+ # `pk` tiebreaker keeps paging deterministic when modified_at collides.
+ ordering = ["-modified_at", "pk"]
+ ordering_fields = ["workflow_name", "created_at", "modified_at"]
notification_resource_name_field = "workflow_name"
def get_notification_resource_type(self, resource: Any) -> str | None:
@@ -104,28 +107,26 @@ def get_queryset(self) -> QuerySet:
WorkflowKey.WF_IS_ACTIVE,
WorkflowKey.WF_NAME,
)
- # Use for_user method to include shared workflows
- queryset = (
- Workflow.objects.for_user(self.request.user).filter(**filter_args)
- if filter_args
- else Workflow.objects.for_user(self.request.user)
- )
- # Avoid per-row queries for owner/co-owner + creator fields in list views
+ # Use for_user to include shared workflows; prefetch owner/co-owner
+ # joins to avoid per-row queries in the Owned By column.
+ queryset = Workflow.objects.for_user(self.request.user)
+ if filter_args:
+ queryset = queryset.filter(**filter_args)
queryset = queryset.select_related("created_by").prefetch_related(
"memberships__user"
)
search = self.request.query_params.get("search")
if search:
- queryset = queryset.filter(workflow_name__icontains=search)
+ from django.db.models import Q
+ from tenant_account_v2.sharing_helpers import (
+ resources_matching_owner_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 == "asc":
- queryset = queryset.order_by("modified_at", "id")
- else:
- queryset = queryset.order_by("-modified_at", "id")
+ queryset = queryset.filter(
+ Q(workflow_name__icontains=search)
+ | Q(pk__in=resources_matching_owner_search(queryset.model, search))
+ )
return queryset
diff --git a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx
index 026c9919cb..f982e37221 100644
--- a/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx
+++ b/frontend/src/components/agency/configure-connector-modal/ConfigureConnectorModal.jsx
@@ -14,6 +14,7 @@ import { cloneDeep, isEqual } from "lodash";
import PropTypes from "prop-types";
import { useCallback, useEffect, useRef, useState } from "react";
+import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
import usePostHogEvents from "../../../hooks/usePostHogEvents";
@@ -141,15 +142,11 @@ function ConfigureConnectorModal({
setIsLoadingConnectors(true);
- const requestOptions = {
- method: "GET",
- url: getUrl(`connector/?connector_mode=${connectionType}`),
- };
-
- axiosPrivate(requestOptions)
- .then((response) => {
- const connectors = response?.data || [];
-
+ fetchAllPages(axiosPrivate, {
+ url: getUrl("connector/"),
+ params: { connector_mode: connectionType },
+ })
+ .then((connectors) => {
// Separate regular connectors from "Add new connector" option
const regularConnectors = connectors.map((conn) => ({
value: conn?.id,
diff --git a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx
index 80a0bd67d2..af1edd9525 100644
--- a/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx
+++ b/frontend/src/components/custom-tools/adapter-selection-modal/AdapterSelectionModal.jsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import "./AdapterSelectionModal.css";
+import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
import { useAlertStore } from "../../../store/alert-store";
@@ -45,8 +46,7 @@ function AdapterSelectionModal({
try {
const adapterTypes = ["LLM", "EMBEDDING", "VECTOR_DB", "X2TEXT"];
const requests = adapterTypes.map((type) =>
- axiosPrivate({
- method: "GET",
+ fetchAllPages(axiosPrivate, {
url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`,
headers: {
"X-CSRFToken": sessionDetails?.csrfToken,
@@ -57,14 +57,9 @@ function AdapterSelectionModal({
}),
);
- const responses = await Promise.all(requests);
+ const [llm, embedding, vectorDb, x2text] = await Promise.all(requests);
- setAdapters({
- llm: responses[0]?.data || [],
- embedding: responses[1]?.data || [],
- vectorDb: responses[2]?.data || [],
- x2text: responses[3]?.data || [],
- });
+ setAdapters({ llm, embedding, vectorDb, x2text });
} catch (err) {
setAlertDetails(
handleException(err, "Failed to fetch available adapters"),
diff --git a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx
index 80cf854c2d..c76e073e1b 100644
--- a/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx
+++ b/frontend/src/components/custom-tools/add-llm-profile/AddLlmProfile.jsx
@@ -20,6 +20,7 @@ import PropTypes from "prop-types";
import { useEffect, useState } from "react";
import { getBackendErrorDetail } from "../../../helpers/GetStaticData";
+import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
import { useAlertStore } from "../../../store/alert-store";
@@ -195,15 +196,10 @@ function AddLlmProfile({
};
const setAdaptorProfilesDropdown = () => {
- const requestOptions = {
- method: "GET",
- url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter`,
- };
-
- axiosPrivate(requestOptions)
- .then((res) => {
- const data = res?.data;
-
+ fetchAllPages(axiosPrivate, {
+ url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`,
+ })
+ .then((data) => {
const llm = [];
const vectorDb = [];
const embedding = [];
diff --git a/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx b/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx
index a80d0d145c..363aac078f 100644
--- a/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx
+++ b/frontend/src/components/custom-tools/combined-output/CombinedOutput.jsx
@@ -12,6 +12,7 @@ import {
getLLMModelNamesForProfiles,
promptType,
} from "../../../helpers/GetStaticData";
+import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useAlertStore } from "../../../store/alert-store";
import { useCustomToolStore } from "../../../store/custom-tool-store";
@@ -146,8 +147,7 @@ function CombinedOutput({ docId, setFilledFields, selectedPrompts }) {
url = publicAdapterApi(id, "LLM");
}
try {
- const res = await axiosPrivate.get(url);
- const adapterList = res?.data;
+ const adapterList = await fetchAllPages(axiosPrivate, { url });
setAdapterData(getLLMModelNamesForProfiles(llmProfiles, adapterList));
} catch (err) {
setAlertDetails(
diff --git a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx
index 38fb925a9c..9f2d403007 100644
--- a/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx
+++ b/frontend/src/components/custom-tools/list-of-tools/ListOfTools.jsx
@@ -1,24 +1,33 @@
import { ArrowDownOutlined, PlusOutlined } from "@ant-design/icons";
import { Space } from "antd";
import PropTypes from "prop-types";
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
+import {
+ applyPagedResponse,
+ buildPagedParams,
+ usePaginatedList,
+} from "../../../hooks/usePaginatedList";
import usePostHogEvents from "../../../hooks/usePostHogEvents.js";
import { useAlertStore } from "../../../store/alert-store";
import { useSessionStore } from "../../../store/session-store";
import { groupsService } from "../../groups/groups-service.js";
import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar";
-import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement";
+import { CoOwnerModal } from "../../widgets/co-owner-management/CoOwnerModal";
import { CustomButton } from "../../widgets/custom-button/CustomButton";
+import { EmptyState } from "../../widgets/empty-state/EmptyState.jsx";
+import { ResourceTable } from "../../widgets/resource-table/ResourceTable";
import { SharePermission } from "../../widgets/share-permission/SharePermission";
+import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
import { AddCustomToolFormModal } from "../add-custom-tool-form-modal/AddCustomToolFormModal";
import { ImportTool } from "../import-tool/ImportTool";
-import { ViewTools } from "../view-tools/ViewTools";
import "./ListOfTools.css";
+const DEFAULT_PAGE_SIZE = 10;
+
const DefaultCustomButtons = ({
setOpenImportTool,
isImportLoading,
@@ -52,7 +61,7 @@ DefaultCustomButtons.propTypes = {
};
function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
- const [isListLoading, setIsListLoading] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
const [openAddTool, setOpenAddTool] = useState(false);
const [openImportTool, setOpenImportTool] = useState(false);
const [isImportLoading, setIsImportLoading] = useState(false);
@@ -64,8 +73,10 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
const handleException = useExceptionHandler();
const groupsApi = groupsService();
- const [listOfTools, setListOfTools] = useState([]);
- const [filteredListOfTools, setFilteredListOfTools] = useState([]);
+ // undefined = not fetched yet (spinner); [] = fetched-empty (empty state)
+ const [displayList, setDisplayList] = useState();
+ // Fetch failure (vs. genuinely empty) — drives a retryable error state.
+ const [loadError, setLoadError] = useState(false);
const [isEdit, setIsEdit] = useState(false);
const [promptDetails, setPromptDetails] = useState(null);
const [openSharePermissionModal, setOpenSharePermissionModal] =
@@ -73,6 +84,10 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
const [isPermissionEdit, setIsPermissionEdit] = useState(false);
const [isShareLoading, setIsShareLoading] = useState(false);
const [allUserList, setAllUserList] = useState([]);
+ const [allGroupList, setAllGroupList] = useState([]);
+ // Monotonic request token so a stale response can't overwrite a newer one.
+ const seqRef = useRef(0);
+
const promptStudioCoOwnerService = useMemo(
() => ({
getAllUsers: () =>
@@ -107,61 +122,111 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
);
const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ pagination,
+ setPagination,
+ searchTerm,
+ setSearchTerm,
+ sort,
+ userSorted,
+ fetchRef,
+ requestList,
+ syncRequested,
+ handlePaginationChange,
+ handleSearch,
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({
+ defaultPageSize: DEFAULT_PAGE_SIZE,
+ defaultSortBy: "modified_at",
+ defaultOrder: "desc",
+ });
+
+ const coOwner = useCoOwnerManagement({
service: promptStudioCoOwnerService,
setAlertDetails,
- onListRefresh: () => getListOfTools(),
+ onListRefresh: handleListRefresh,
});
- const [allGroupList, setAllGroupList] = useState([]);
- useEffect(() => {
- getListOfTools();
- }, []);
+ const getListOfTools = useCallback(
+ (
+ page = 1,
+ pageSize = DEFAULT_PAGE_SIZE,
+ search = "",
+ sortBy = "",
+ order = "asc",
+ ) => {
+ const params = buildPagedParams({
+ page,
+ pageSize,
+ search,
+ sortBy,
+ order,
+ });
+ const seq = ++seqRef.current;
+ setLoadError(false);
+ setIsLoading(true);
+ return axiosPrivate({
+ method: "GET",
+ url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/`,
+ headers: { "X-CSRFToken": sessionDetails?.csrfToken },
+ params,
+ })
+ .then((res) =>
+ applyPagedResponse({
+ data: res?.data,
+ page,
+ pageSize,
+ seq,
+ latestSeqRef: seqRef,
+ setList: setDisplayList,
+ setPagination,
+ refetchPrevPage: () =>
+ requestList(page - 1, pageSize, search, sortBy, order),
+ }),
+ )
+ .catch((err) => {
+ // A newer request superseded this one — don't surface its error.
+ if (seq !== seqRef.current) {
+ return;
+ }
+ setAlertDetails(
+ handleException(err, "Failed to get the list of tools"),
+ );
+ // Surface a retryable error instead of a misleading empty state.
+ setLoadError(true);
+ // Failed request — realign requestedRef with the still-shown view.
+ syncRequested();
+ })
+ .finally(() => {
+ // Only the newest request owns the shared loading state.
+ if (seq === seqRef.current) {
+ setIsLoading(false);
+ }
+ });
+ },
+ [
+ sessionDetails?.orgId,
+ sessionDetails?.csrfToken,
+ axiosPrivate,
+ setPagination,
+ setAlertDetails,
+ handleException,
+ ],
+ );
+ fetchRef.current = getListOfTools;
useEffect(() => {
- setFilteredListOfTools(listOfTools);
- }, [listOfTools]);
-
- const getListOfTools = () => {
- const requestOptions = {
- method: "GET",
- url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/`,
- headers: {
- "X-CSRFToken": sessionDetails?.csrfToken,
- },
- };
-
- setIsListLoading(true);
- axiosPrivate(requestOptions)
- .then((res) => {
- const data = res?.data;
- setListOfTools(data);
- setFilteredListOfTools(data);
- })
- .catch((err) => {
- setAlertDetails(
- handleException(err, "Failed to get the list of tools"),
- );
- })
- .finally(() => {
- setIsListLoading(false);
- });
- };
+ setSearchTerm("");
+ setDisplayList(undefined);
+ requestList(1, DEFAULT_PAGE_SIZE, "", sort.sortBy, sort.order);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
const handleAddNewTool = (body) => {
let method = "POST";
let url = `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/`;
- const isEdit = editItem && Object.keys(editItem)?.length > 0;
- if (isEdit) {
+ const isEditFlow = editItem && Object.keys(editItem)?.length > 0;
+ if (isEditFlow) {
method = "PATCH";
url += `${editItem?.tool_id}/`;
}
@@ -178,8 +243,10 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
axiosPrivate(requestOptions)
.then((res) => {
- const tool = res?.data;
- updateList(isEdit, tool);
+ setEditItem(null);
+ // Refetch the current page to reflect server truth rather than
+ // splicing a stale list (list-only fields like prompt_count).
+ handleListRefresh();
setOpenAddTool(false);
resolve(res?.data);
})
@@ -189,31 +256,12 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
});
};
- const updateList = (isEdit, data) => {
- let tools = [...listOfTools];
-
- if (isEdit) {
- // Merge — the PATCH response (CustomToolSerializer) lacks list-only
- // fields like prompt_count; replacing wholesale would drop them
- tools = tools.map((item) =>
- item?.tool_id === data?.tool_id ? { ...item, ...data } : item,
- );
- setEditItem(null);
- } else {
- tools.push(data);
- }
- setListOfTools(tools);
- };
-
const handleEdit = (_event, tool) => {
- const editToolData = [...listOfTools].find(
- (item) => item?.tool_id === tool.tool_id,
- );
- if (!editToolData) {
+ if (!tool) {
return;
}
setIsEdit(true);
- setEditItem(editToolData);
+ setEditItem(tool);
setOpenAddTool(true);
};
@@ -227,29 +275,12 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
};
axiosPrivate(requestOptions)
- .then(() => {
- const tools = [...listOfTools].filter(
- (filterToll) => filterToll?.tool_id !== tool.tool_id,
- );
- setListOfTools(tools);
- })
+ .then(() => handleListRefresh())
.catch((err) => {
setAlertDetails(handleException(err, "Failed to Delete"));
});
};
- const onSearch = (search, setSearch) => {
- if (search?.length === 0) {
- setSearch(listOfTools);
- }
- const filteredList = [...listOfTools].filter((tool) => {
- const name = tool.tool_name?.toUpperCase();
- const searchUpperCase = search.toUpperCase();
- return name.includes(searchUpperCase);
- });
- setSearch(filteredList);
- };
-
const showAddTool = () => {
setEditItem(null);
setIsEdit(false);
@@ -315,7 +346,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
setOpenImportTool(false);
// Refresh the list of tools to show the new imported project
- getListOfTools();
+ handleListRefresh();
})
.catch((err) => {
setAlertDetails(handleException(err, "Failed to import project"));
@@ -325,7 +356,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
});
};
- const handleShare = (_event, promptProject, isEdit) => {
+ const handleShare = (_event, promptProject, isEditShare) => {
const requestOptions = {
method: "GET",
url: `/api/v1/unstract/${sessionDetails?.orgId}/prompt-studio/users/${promptProject?.tool_id}`,
@@ -346,7 +377,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
.then((res) => {
setOpenSharePermissionModal(true);
setPromptDetails(res?.data);
- setIsPermissionEdit(isEdit);
+ setIsPermissionEdit(isEditShare);
})
.catch((err) => {
setAlertDetails(handleException(err));
@@ -407,30 +438,9 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
};
const handleCoOwner = (_event, tool) => {
- handleCoOwnerAction(tool.tool_id);
+ coOwner.handleCoOwner(tool.tool_id);
};
- const defaultContent = (
-
-
-
- );
-
const customButtonsElement = useMemo(
() => (
handleSearch(value)}
customButtons={customButtonsElement}
segmentOptions={segmentOptions}
segmentValue={segmentValue}
segmentFilter={onSegmentChange}
/>
-
{defaultContent}
+
+
+ {loadError && (
+
+ )}
+ {!loadError && displayList === undefined && }
+ {!loadError && displayList?.length === 0 && !searchTerm && (
+
+ )}
+ {!loadError && displayList?.length === 0 && searchTerm && (
+
+ )}
+ {!loadError && displayList?.length > 0 && (
+
+ )}
+
+
{openAddTool && (
-
+
>
);
}
diff --git a/frontend/src/components/custom-tools/view-tools/ViewTools.css b/frontend/src/components/custom-tools/view-tools/ViewTools.css
deleted file mode 100644
index 329bdd21cf..0000000000
--- a/frontend/src/components/custom-tools/view-tools/ViewTools.css
+++ /dev/null
@@ -1 +0,0 @@
-/* Styles for ViewTools */
diff --git a/frontend/src/components/custom-tools/view-tools/ViewTools.jsx b/frontend/src/components/custom-tools/view-tools/ViewTools.jsx
deleted file mode 100644
index d5dca62837..0000000000
--- a/frontend/src/components/custom-tools/view-tools/ViewTools.jsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import PropTypes from "prop-types";
-
-import { ListView } from "../../widgets/list-view/ListView";
-import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
-import "./ViewTools.css";
-import { EmptyState } from "../../widgets/empty-state/EmptyState.jsx";
-
-function ViewTools({
- isLoading,
- isEmpty,
- listOfTools,
- setOpenAddTool,
- handleEdit,
- handleDelete,
- titleProp,
- descriptionProp,
- iconProp,
- idProp,
- centered,
- isClickable = true,
- handleShare,
- handleCoOwner,
- showOwner,
- showModified,
- type,
-}) {
- if (isLoading) {
- return ;
- }
-
- if (isEmpty) {
- let text = "No tools available";
- let btnText = "New Tool";
- if (type) {
- text = `No ${type.toLowerCase()} available`;
- btnText = type;
- }
- return (
- setOpenAddTool(true)}
- />
- );
- }
-
- if (!listOfTools?.length) {
- return ;
- }
-
- return (
-
- );
-}
-
-ViewTools.propTypes = {
- isLoading: PropTypes.bool.isRequired,
- isEmpty: PropTypes.bool.isRequired,
- listOfTools: PropTypes.array,
- setOpenAddTool: PropTypes.func,
- handleEdit: PropTypes.func.isRequired,
- handleDelete: PropTypes.func.isRequired,
- handleShare: PropTypes.func,
- handleCoOwner: PropTypes.func,
- titleProp: PropTypes.string.isRequired,
- descriptionProp: PropTypes.string,
- iconProp: PropTypes.string,
- idProp: PropTypes.string.isRequired,
- centered: PropTypes.bool,
- isClickable: PropTypes.bool,
- showOwner: PropTypes.bool,
- showModified: PropTypes.bool,
- type: PropTypes.string,
-};
-
-export { ViewTools };
diff --git a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx
index 29f8f17612..b1e5bd0708 100644
--- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx
+++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx
@@ -6,7 +6,10 @@ import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js";
import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { useExecutionLogs } from "../../../hooks/useExecutionLogs";
-import { usePaginatedList } from "../../../hooks/usePaginatedList";
+import {
+ applyPagedResponse,
+ usePaginatedList,
+} from "../../../hooks/usePaginatedList";
import usePipelineHelper from "../../../hooks/usePipelineHelper.js";
import {
useInitialFetchCount,
@@ -44,6 +47,8 @@ function ApiDeployment() {
const [openManageKeysModal, setOpenManageKeysModal] = useState(false);
const [selectedRow, setSelectedRow] = useState({});
const [tableData, setTableData] = useState([]);
+ // Monotonic request token so a stale response can't overwrite a newer one.
+ const seqRef = useRef(0);
const [filteredData, setFilteredData] = useState([]);
const [apiKeys, setApiKeys] = useState([]);
const [isEdit, setIsEdit] = useState(false);
@@ -71,26 +76,23 @@ function ApiDeployment() {
const { count, isLoading, fetchCount } = usePromptStudioStore();
const { getPromptStudioCount } = usePromptStudioService();
- // Ref to forward the fetch function to hooks (avoids declaration ordering)
- const fetchListRef = useRef(null);
-
const {
pagination,
setPagination,
searchTerm,
setSearchTerm,
+ // The hook owns the fetch ref; assigned below (avoids declaration ordering).
+ fetchRef,
handlePaginationChange,
handleSearch,
- } = usePaginatedList({
- fetchData: (...args) => fetchListRef.current?.(...args),
- });
+ } = usePaginatedList();
const { scrollRestoreId, activateScrollRestore, clearPendingScroll } =
useScrollRestoration({
location,
setSearchTerm,
setPagination,
- fetchData: (...args) => fetchListRef.current?.(...args),
+ fetchData: (...args) => fetchRef.current?.(...args),
});
const {
@@ -158,31 +160,43 @@ function ApiDeployment() {
const getApiDeploymentList = (page = 1, pageSize = 10, search = "") => {
setIsTableLoading(true);
- apiDeploymentsApiService
+ const seq = ++seqRef.current;
+ return apiDeploymentsApiService
.getApiDeploymentsList(page, pageSize, search)
.then((res) => {
- const data = res?.data;
- const results = data.results || data;
- setTableData(results);
- setPagination((prev) => ({
- ...prev,
- current: page,
+ const stepback = applyPagedResponse({
+ data: res?.data,
+ page,
pageSize,
- total: data.count ?? data.results?.length ?? data.length,
- }));
-
- activateScrollRestore();
+ seq,
+ latestSeqRef: seqRef,
+ setList: setTableData,
+ setPagination,
+ refetchPrevPage: () =>
+ getApiDeploymentList(page - 1, pageSize, search),
+ });
+ if (seq === seqRef.current) {
+ activateScrollRestore();
+ }
+ return stepback;
})
.catch((err) => {
+ // A newer request superseded this one — don't surface its error.
+ if (seq !== seqRef.current) {
+ return;
+ }
setAlertDetails(handleException(err));
clearPendingScroll();
})
.finally(() => {
- setIsTableLoading(false);
+ // Only the newest request owns the shared loading state.
+ if (seq === seqRef.current) {
+ setIsTableLoading(false);
+ }
});
};
- fetchListRef.current = getApiDeploymentList;
+ fetchRef.current = getApiDeploymentList;
const deleteApiDeployment = (item) => {
const id = item?.id || selectedRow.id;
diff --git a/frontend/src/components/helpers/custom-tools/CustomToolsHelper.js b/frontend/src/components/helpers/custom-tools/CustomToolsHelper.js
index 35c2854cb0..5a9dcb7574 100644
--- a/frontend/src/components/helpers/custom-tools/CustomToolsHelper.js
+++ b/frontend/src/components/helpers/custom-tools/CustomToolsHelper.js
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom";
+import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
import { useAlertStore } from "../../../store/alert-store";
@@ -117,16 +118,13 @@ function CustomToolsHelper() {
.then((res) => {
const data = res?.data;
updatedCusTool["shareId"] = data?.share_id;
- const reqOpsLlmProfiles = {
- method: "GET",
- url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`,
- };
- return handleApiRequest(reqOpsLlmProfiles);
+ return fetchAllPages(axiosPrivate, {
+ url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`,
+ });
})
- .then((res) => {
- const data = res?.data;
- updatedCusTool["adapters"] = data;
+ .then((adapters) => {
+ updatedCusTool["adapters"] = adapters;
if (fetchLookupAssignments) {
const toolId = updatedCusTool["details"]?.tool_id;
diff --git a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
index e39c50ff1e..1a979eb6ca 100644
--- a/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
+++ b/frontend/src/components/navigations/tool-nav-bar/ToolNavBar.jsx
@@ -23,6 +23,7 @@ function ToolNavBar({
segmentValue,
onSearch,
searchKey,
+ searchPlaceholder = "Search by name",
}) {
const navigate = useNavigate();
const onSearchDebounce = debounce(({ target: { value } }) => {
@@ -93,7 +94,7 @@ function ToolNavBar({
@@ -120,6 +121,7 @@ ToolNavBar.propTypes = {
segmentFilter: PropTypes.func,
onSearch: PropTypes.func,
searchKey: PropTypes.string,
+ searchPlaceholder: PropTypes.string,
};
export { ToolNavBar };
diff --git a/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx b/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx
index b3803ee79e..a6330bdb21 100644
--- a/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx
+++ b/frontend/src/components/pipelines-or-deployments/etl-task-deploy/EtlTaskDeploy.jsx
@@ -82,8 +82,8 @@ const EtlTaskDeploy = ({
const getWorkflowList = () => {
workflowApiService
.getWorkflowList()
- .then((res) => {
- setWorkflowList(res?.data);
+ .then((workflows) => {
+ setWorkflowList(workflows);
})
.catch(() => {
console.error("Unable to get workflow list");
diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
index 32fdd4ae5b..edde00911e 100644
--- a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
+++ b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
@@ -11,7 +11,10 @@ import useClearFileHistory from "../../../hooks/useClearFileHistory";
import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement.jsx";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { useExecutionLogs } from "../../../hooks/useExecutionLogs";
-import { usePaginatedList } from "../../../hooks/usePaginatedList";
+import {
+ applyPagedResponse,
+ usePaginatedList,
+} from "../../../hooks/usePaginatedList";
import usePipelineHelper from "../../../hooks/usePipelineHelper.js";
import {
useInitialFetchCount,
@@ -39,6 +42,8 @@ import "./Pipelines.css";
function Pipelines({ type }) {
const [tableData, setTableData] = useState([]);
+ // Monotonic request token so a stale response can't overwrite a newer one.
+ const seqRef = useRef(0);
const [openEtlOrTaskModal, setOpenEtlOrTaskModal] = useState(false);
const [selectedPorD, setSelectedPorD] = useState({});
const [tableLoading, setTableLoading] = useState(true);
@@ -75,26 +80,23 @@ function Pipelines({ type }) {
const { count, isLoading, fetchCount } = usePromptStudioStore();
const { getPromptStudioCount } = usePromptStudioService();
- // Ref to forward the fetch function to hooks (avoids declaration ordering)
- const fetchListRef = useRef(null);
-
const {
pagination,
setPagination,
searchTerm,
setSearchTerm,
+ // The hook owns the fetch ref; assigned below (avoids declaration ordering).
+ fetchRef,
handlePaginationChange,
handleSearch,
- } = usePaginatedList({
- fetchData: (...args) => fetchListRef.current?.(...args),
- });
+ } = usePaginatedList();
const { scrollRestoreId, activateScrollRestore, clearPendingScroll } =
useScrollRestoration({
location,
setSearchTerm,
setPagination,
- fetchData: (...args) => fetchListRef.current?.(...args),
+ fetchData: (...args) => fetchRef.current?.(...args),
});
const {
@@ -161,30 +163,41 @@ function Pipelines({ type }) {
params,
};
- axiosPrivate(requestOptions)
+ const seq = ++seqRef.current;
+ return axiosPrivate(requestOptions)
.then((res) => {
- const data = res?.data;
- // Handle paginated response
- setTableData(data.results || data);
- setPagination((prev) => ({
- ...prev,
- current: page,
+ const stepback = applyPagedResponse({
+ data: res?.data,
+ page,
pageSize,
- total: data.count ?? data.results?.length ?? data.length ?? 0,
- }));
-
- activateScrollRestore();
+ seq,
+ latestSeqRef: seqRef,
+ setList: setTableData,
+ setPagination,
+ refetchPrevPage: () => getPipelineList(page - 1, pageSize, search),
+ });
+ if (seq === seqRef.current) {
+ activateScrollRestore();
+ }
+ return stepback;
})
.catch((err) => {
+ // A newer request superseded this one — don't surface its error.
+ if (seq !== seqRef.current) {
+ return;
+ }
setAlertDetails(handleException(err));
clearPendingScroll();
})
.finally(() => {
- setTableLoading(false);
+ // Only the newest request owns the shared loading state.
+ if (seq === seqRef.current) {
+ setTableLoading(false);
+ }
});
};
- fetchListRef.current = getPipelineList;
+ fetchRef.current = getPipelineList;
const handleSync = (params) => {
const body = { ...params, pipeline_type: type.toUpperCase() };
diff --git a/frontend/src/components/settings/default-triad/DefaultTriad.jsx b/frontend/src/components/settings/default-triad/DefaultTriad.jsx
index 141f630e4c..4496f3faf5 100644
--- a/frontend/src/components/settings/default-triad/DefaultTriad.jsx
+++ b/frontend/src/components/settings/default-triad/DefaultTriad.jsx
@@ -3,6 +3,7 @@ import { Button, Select, Typography } from "antd";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { fetchAllPages } from "../../../helpers/pagination";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler.jsx";
import { IslandLayout } from "../../../layouts/island-layout/IslandLayout.jsx";
@@ -61,14 +62,11 @@ function DefaultTriad() {
};
const fetchData = () => {
- const requestOptions = {
- method: "GET",
+ fetchAllPages(axiosPrivate, {
url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/`,
- };
- axiosPrivate(requestOptions)
- .then((res) => {
- const data = res?.data;
- setAdapterList(data);
+ })
+ .then((adapters) => {
+ setAdapterList(adapters);
})
.catch((err) => {
setAlertDetails(
diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
index 04f41d7694..67c256388c 100644
--- a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
+++ b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
@@ -1,23 +1,29 @@
import { PlusOutlined } from "@ant-design/icons";
import PropTypes from "prop-types";
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate";
import { useCoOwnerManagement } from "../../../hooks/useCoOwnerManagement";
import { useExceptionHandler } from "../../../hooks/useExceptionHandler";
-import { useListSearch } from "../../../hooks/useListSearch";
+import {
+ applyPagedResponse,
+ buildPagedParams,
+ usePaginatedList,
+} from "../../../hooks/usePaginatedList";
import usePostHogEvents from "../../../hooks/usePostHogEvents";
import { IslandLayout } from "../../../layouts/island-layout/IslandLayout";
import { useAlertStore } from "../../../store/alert-store";
import { useSessionStore } from "../../../store/session-store";
-import { ViewTools } from "../../custom-tools/view-tools/ViewTools";
import { groupsService } from "../../groups/groups-service.js";
import { AddSourceModal } from "../../input-output/add-source-modal/AddSourceModal";
import "../../input-output/data-source-card/DataSourceCard.css";
import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar";
-import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement";
+import { CoOwnerModal } from "../../widgets/co-owner-management/CoOwnerModal";
import { CustomButton } from "../../widgets/custom-button/CustomButton";
+import { EmptyState } from "../../widgets/empty-state/EmptyState.jsx";
+import { ResourceTable } from "../../widgets/resource-table/ResourceTable";
import { SharePermission } from "../../widgets/share-permission/SharePermission";
+import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
import "./ToolSettings.css";
const titles = {
@@ -36,6 +42,8 @@ const btnText = {
ocr: "New OCR",
};
+const DEFAULT_PAGE_SIZE = 10;
+
function ToolSettings({ type }) {
const [isLoading, setIsLoading] = useState(false);
const [isShareLoading, setIsShareLoading] = useState(false);
@@ -48,10 +56,16 @@ function ToolSettings({ type }) {
useState(false);
const [isPermissonEdit, setIsPermissionEdit] = useState(false);
const [editItemId, setEditItemId] = useState(null);
+ // undefined = not fetched yet (spinner); [] = fetched-empty (empty state)
+ const [displayList, setDisplayList] = useState();
+ // Fetch failure (vs. genuinely empty) — drives a retryable error state.
+ const [loadError, setLoadError] = useState(false);
const { sessionDetails } = useSessionStore();
const { setAlertDetails } = useAlertStore();
const axiosPrivate = useAxiosPrivate();
const handleException = useExceptionHandler();
+ // Monotonic request token so a stale response can't overwrite a newer one.
+ const seqRef = useRef(0);
const adapterCoOwnerService = useMemo(
() => ({
@@ -87,95 +101,140 @@ function ToolSettings({ type }) {
);
const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ pagination,
+ setPagination,
+ searchTerm,
+ sort,
+ userSorted,
+ fetchRef,
+ requestList,
+ resetList,
+ syncRequested,
+ handlePaginationChange,
+ handleSearch,
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({
+ defaultPageSize: DEFAULT_PAGE_SIZE,
+ defaultSortBy: "modified_at",
+ defaultOrder: "desc",
+ });
+
+ const coOwner = useCoOwnerManagement({
service: adapterCoOwnerService,
setAlertDetails,
- onListRefresh: () => getAdapters(),
+ onListRefresh: handleListRefresh,
});
const { posthogEventText, setPostHogCustomEvent } = usePostHogEvents();
- const {
- listRef,
- displayList,
- setDisplayList,
- setMasterList,
- updateMasterList,
- onSearch,
- clearSearch,
- } = useListSearch("adapter_name");
+
+ const getAdapters = useCallback(
+ (
+ page = 1,
+ pageSize = DEFAULT_PAGE_SIZE,
+ search = "",
+ sortBy = "",
+ order = "asc",
+ ) => {
+ if (!type) {
+ return;
+ }
+ const params = buildPagedParams({
+ page,
+ pageSize,
+ search,
+ sortBy,
+ order,
+ });
+ params.adapter_type = type.toUpperCase();
+ const seq = ++seqRef.current;
+ setLoadError(false);
+ setIsLoading(true);
+ return axiosPrivate({
+ method: "GET",
+ url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter`,
+ params,
+ })
+ .then((res) =>
+ applyPagedResponse({
+ data: res?.data,
+ page,
+ pageSize,
+ seq,
+ latestSeqRef: seqRef,
+ setList: setDisplayList,
+ setPagination,
+ refetchPrevPage: () =>
+ requestList(page - 1, pageSize, search, sortBy, order),
+ }),
+ )
+ .catch((err) => {
+ // A newer request superseded this one — don't surface its error.
+ if (seq !== seqRef.current) {
+ return;
+ }
+ setAlertDetails(handleException(err));
+ // Surface a retryable error instead of a misleading empty state.
+ setLoadError(true);
+ // Failed request — realign requestedRef with the still-shown view.
+ syncRequested();
+ })
+ .finally(() => {
+ // Only the newest request owns the shared loading state.
+ if (seq === seqRef.current) {
+ setIsLoading(false);
+ }
+ });
+ },
+ [
+ type,
+ sessionDetails?.orgId,
+ axiosPrivate,
+ setPagination,
+ setAlertDetails,
+ handleException,
+ ],
+ );
+ fetchRef.current = getAdapters;
useEffect(() => {
- clearSearch();
- setMasterList([]);
+ setDisplayList(undefined);
if (!type) {
return;
}
- getAdapters();
+ // Persistent instance across adapter types: reset search/sort/requestedRef
+ // together so the new type starts clean and later refreshes don't replay
+ // the previous type's view.
+ resetList();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [type]);
- const getAdapters = () => {
- const requestOptions = {
- method: "GET",
- url: `/api/v1/unstract/${
- sessionDetails?.orgId
- }/adapter?adapter_type=${type.toUpperCase()}`,
- };
- setIsLoading(true);
- axiosPrivate(requestOptions)
- .then((res) => {
- setMasterList(res?.data || []);
- })
- .catch((err) => {
- setAlertDetails(handleException(err));
- })
- .finally(() => {
- setIsLoading(false);
- });
- };
-
- const addNewItem = (row, isEdit) => {
- if (isEdit) {
- updateMasterList((currentList) =>
- currentList.map((tableRow) => {
- if (tableRow?.id !== row?.id) {
- return tableRow;
- }
- return { ...tableRow, adapter_name: row?.adapter_name };
- }),
- );
- } else {
- updateMasterList((currentList) => [...currentList, row]);
- }
- };
-
- const handleDeleteSuccess = (adapterId) => {
- updateMasterList((currentList) =>
- currentList.filter((row) => row?.id !== adapterId),
- );
- };
+ // New/edited adapters land on some page under the active sort — refetch the
+ // current page to reflect server truth rather than splicing a stale array.
+ const addNewItem = () => handleListRefresh();
const handleDelete = (_event, adapter) => {
- const requestOptions = {
+ // Don't drive the shared list-loading from a row delete (as the other lists
+ // avoid): success refetches via handleListRefresh, which owns the spinner;
+ // failure just surfaces a toast. Keeps deletes out of the loading races.
+ axiosPrivate({
method: "DELETE",
url: `/api/v1/unstract/${sessionDetails?.orgId}/adapter/${adapter?.id}/`,
- headers: {
- "X-CSRFToken": sessionDetails?.csrfToken,
- },
- };
+ headers: { "X-CSRFToken": sessionDetails?.csrfToken },
+ })
+ .then(() => handleListRefresh())
+ .catch((err) => setAlertDetails(handleException(err)));
+ };
- setIsLoading(true);
- axiosPrivate(requestOptions)
- .then(() => handleDeleteSuccess(adapter?.id))
- .catch((err) => setAlertDetails(handleException(err)))
- .finally(() => setIsLoading(false));
+ const handleEdit = (_event, item) => {
+ if (item?.is_deprecated) {
+ setAlertDetails({
+ type: "error",
+ content:
+ "This adapter has been deprecated and cannot be edited. Please remove it or use an alternative adapter.",
+ });
+ return;
+ }
+ setEditItemId(item?.id);
};
const handleShare = (_event, adapter, isEdit) => {
@@ -268,7 +327,9 @@ function ToolSettings({ type }) {
};
const handleCoOwner = (_event, adapter) => {
- if (!adapter?.id) return;
+ if (!adapter?.id) {
+ return;
+ }
if (adapter?.is_deprecated) {
setAlertDetails({
type: "error",
@@ -276,7 +337,7 @@ function ToolSettings({ type }) {
});
return;
}
- handleCoOwnerAction(adapter.id);
+ coOwner.handleCoOwner(adapter.id);
};
const handleOpenAddSourceModal = () => {
@@ -296,9 +357,9 @@ function ToolSettings({ type }) {
handleSearch(value)}
customButtons={
- {
- // Check if adapter is deprecated
- if (item?.is_deprecated) {
- setAlertDetails({
- type: "error",
- content:
- "This adapter has been deprecated and cannot be edited. Please remove it or use an alternative adapter.",
- });
- return;
- }
- setEditItemId(item?.id);
- }}
- idProp="id"
- titleProp="adapter_name"
- descriptionProp="description"
- iconProp="icon"
- isEmpty={!listRef.current.length}
- centered
- isClickable={false}
- handleShare={handleShare}
- handleCoOwner={handleCoOwner}
- showOwner={true}
- showModified
- type="Adapter"
- />
+ {loadError && (
+
+ )}
+ {!loadError && displayList === undefined && }
+ {!loadError && displayList?.length === 0 && !searchTerm && (
+
+ )}
+ {!loadError && displayList?.length === 0 && searchTerm && (
+
+ )}
+ {!loadError && displayList?.length > 0 && (
+
+ )}
@@ -364,18 +436,7 @@ function ToolSettings({ type }) {
onApply={onShare}
isSharableToOrg={true}
/>
-
+
);
}
diff --git a/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx b/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx
new file mode 100644
index 0000000000..e66be15223
--- /dev/null
+++ b/frontend/src/components/widgets/co-owner-management/CoOwnerModal.jsx
@@ -0,0 +1,37 @@
+import PropTypes from "prop-types";
+
+import { CoOwnerManagement } from "./CoOwnerManagement";
+
+/**
+ * Thin wrapper that maps a `useCoOwnerManagement()` bag plus `resourceType`
+ * onto the CoOwnerManagement modal, so resource list pages don't each repeat
+ * the identical prop wiring.
+ *
+ * @param {Object} props
+ * @param {Object} props.coOwner - The `useCoOwnerManagement()` return value.
+ * @param {string} props.resourceType - Human-readable resource label.
+ * @return {JSX.Element}
+ */
+function CoOwnerModal({ coOwner, resourceType }) {
+ return (
+
+ );
+}
+
+CoOwnerModal.propTypes = {
+ coOwner: PropTypes.object.isRequired,
+ resourceType: PropTypes.string.isRequired,
+};
+
+export { CoOwnerModal };
diff --git a/frontend/src/components/widgets/list-view/ListView.css b/frontend/src/components/widgets/list-view/ListView.css
deleted file mode 100644
index 7ea9b43bb6..0000000000
--- a/frontend/src/components/widgets/list-view/ListView.css
+++ /dev/null
@@ -1,180 +0,0 @@
-/* Styles for ListView */
-
-.list-view-wrapper {
- height: 100%;
- overflow: hidden auto;
- width: 70%;
- min-width: 800px;
- max-width: 1400px;
- padding: 0px 10px 40px 10px;
-}
-
-.list-view-item {
- display: grid;
- grid-auto-flow: row;
- row-gap: 2px;
- cursor: pointer;
-}
-
-.cur-pointer {
- padding: 16px 0 !important;
- z-index: 1;
- overflow: hidden;
-}
-
-.action-button-container {
- display: flex;
- align-items: center;
- gap: 24px;
- padding-right: 12px;
-}
-
-.action-icon-buttons {
- font-size: 18px;
- z-index: 20;
- color: #092c4c;
- cursor: pointer;
- transition: color 0.2s ease;
-}
-
-.list-view-description {
- font-size: 13px;
- margin-bottom: 0 !important;
- /* keep rows uniform; full text is in the ellipsis tooltip */
- max-width: 520px;
-}
-
-.list-view-row {
- width: 100%;
-}
-
-/* Left column: title + description stacked; shrinks with ellipsis */
-.list-view-left {
- display: flex;
- flex: 1 1 45%;
- flex-direction: column;
- gap: 4px;
- min-width: 0;
-}
-
-/* Middle column: owner + updated. align-items only centers the badge and
- the updated-text against each other — the column's own vertical position
- comes from the row Flex's align in ListView.jsx */
-.list-view-meta {
- align-items: center;
- display: flex;
- /* must not shrink: its columns are fixed below, so a shrinking box would
- let them spill over the action icons. The left column absorbs the
- shortfall instead — it ellipsizes, this doesn't. */
- flex: 0 0 auto;
- gap: 20px;
-}
-
-/* The meta cluster is anchored on its right edge (the left column grows to
- absorb the row's slack), so any column that sizes to its content pushes
- the ones before it sideways, row by row. Fixed widths keep every row's
- columns at the same x. */
-.list-view-meta .adapters-list-profile-container {
- flex: 0 0 240px;
- justify-content: flex-start;
-}
-
-.list-view-meta .list-view-modified-container {
- flex: 0 0 170px;
-}
-
-.list-view-meta .shared-username {
- max-width: 200px;
-}
-
-.list-view-divider {
- border-inline-start: solid rgba(5, 5, 5, 0.13);
- height: 20px;
-}
-
-.adapter-cover-img .fit-cover {
- width: 38px;
- height: 38px;
- object-fit: contain;
-}
-
-.adapters-list-profile-container {
- align-items: center;
- display: flex;
- justify-content: center;
- /* let this shrink below its content width so the email ellipsizes
- instead of forcing the row wider (the email itself never wraps —
- it's nowrap + capped at 200px) */
- min-width: 0;
-}
-
-.adapters-list-user-prefix {
- margin: 0 5px;
- font-weight: 500;
- white-space: nowrap;
-}
-
-.adapters-list-title {
- font-size: 16px;
- /* match the agentic Prompt Studio project list */
- font-weight: 600;
-}
-
-.adapter-cover-img {
- display: flex;
- align-items: center;
- gap: 5px;
-}
-
-/* flex item: allow the title to shrink so its ellipsis can engage */
-.adapter-cover-img .adapters-list-title {
- min-width: 0;
-}
-
-.list-view-modified-container {
- align-items: center;
- display: flex;
- gap: 6px;
- white-space: nowrap;
-}
-
-.list-view-modified-text {
- font-size: 12px;
-}
-
-.list-view-info-icon {
- color: #8c8c8c;
- font-size: 13px;
-}
-
-.edit-icon:hover {
- color: #1890ff;
-}
-
-.delete-icon:hover {
- color: #ff4d4f;
-}
-
-.share-icon:hover {
- color: #1890ff;
-}
-
-.owner-badge-btn {
- background: none;
- border: none;
- padding: 0;
- font: inherit;
- color: inherit;
-}
-
-.owner-clickable {
- cursor: pointer;
-}
-
-.owner-clickable:hover .adapters-list-user-avatar {
- background-color: #1890ff;
-}
-
-.owner-clickable:hover .shared-username {
- color: #1890ff;
-}
diff --git a/frontend/src/components/widgets/list-view/ListView.jsx b/frontend/src/components/widgets/list-view/ListView.jsx
deleted file mode 100644
index 14407c69c5..0000000000
--- a/frontend/src/components/widgets/list-view/ListView.jsx
+++ /dev/null
@@ -1,336 +0,0 @@
-import {
- Avatar,
- Flex,
- Image,
- List,
- Popconfirm,
- Tooltip,
- Typography,
-} from "antd";
-import PropTypes from "prop-types";
-import "./ListView.css";
-import {
- DeleteOutlined,
- EditOutlined,
- InfoCircleOutlined,
- QuestionCircleOutlined,
- ShareAltOutlined,
- UserOutlined,
-} from "@ant-design/icons";
-import { useNavigate } from "react-router-dom";
-
-import { formattedDateTime, timeAgo } from "../../../helpers/GetStaticData";
-import { useSessionStore } from "../../../store/session-store";
-
-// Tooltip lines render when the value is present (`!= null`: 0 shows,
-// null/undefined hide). The "Updated" block itself is opt-in via
-// showModified — field presence alone doesn't prove the page's value is
-// an honest "last modified".
-const renderItemMetadata = (item) => (
-
- {item?.created_at && (
-
Created: {formattedDateTime(item.created_at)}
- )}
- {item?.modified_at && (
-
Modified: {formattedDateTime(item.modified_at)}
- )}
- {item?.model != null &&
Model: {item.model}
}
- {item?.prompt_count != null &&
Prompts: {item.prompt_count}
}
-
-);
-
-function ListView({
- listOfTools,
- handleEdit,
- handleDelete,
- handleShare,
- handleCoOwner,
- titleProp,
- descriptionProp,
- iconProp,
- idProp,
- centered,
- isClickable = true,
- showOwner = true,
- showModified = false,
- type,
-}) {
- const navigate = useNavigate();
- const { sessionDetails } = useSessionStore();
- const handleDeleteClick = (event, tool) => {
- event.stopPropagation(); // Stop propagation to prevent list item click
- handleDelete(event, tool);
- };
-
- const handleShareClick = (event, tool, isEdit) => {
- event.stopPropagation(); // Stop propagation to prevent list item click
- handleShare(event, tool, isEdit);
- };
-
- const handleCoOwnerClick = (event, tool) => {
- event.stopPropagation();
- handleCoOwner(event, tool);
- };
-
- const renderOwnerBadge = (item) => {
- // ``is_owner``/``co_owners_count`` come from resources migrated to the
- // membership model (co-owners). Resources not yet migrated fall back to the
- // created_by-email comparison so their owner badge is unchanged.
- const hasMembership = item?.co_owners_count !== undefined;
- let name = "-";
- if (hasMembership) {
- name = item?.is_owner ? "Me" : item?.created_by_email || "-";
- } else if (item?.created_by_email) {
- name =
- item.created_by_email === sessionDetails?.email
- ? "Me"
- : item.created_by_email;
- }
- const extra =
- item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : "";
- const ownerLabel = `${name}${extra}`;
-
- const badgeContent = (
- <>
- }
- />
-
- Owned By:
-
-
- {ownerLabel}
-
- >
- );
-
- if (handleCoOwner) {
- return (
-
-
-
- );
- }
-
- return (
- {badgeContent}
- );
- };
-
- const renderTitle = (item) => {
- let title = null;
- if (iconProp && item[iconProp].length > 4) {
- title = (
-
-
-
- {item[titleProp]}
-
-
- );
- } else if (iconProp) {
- title = (
-
- {`${item[iconProp]} ${item[titleProp]}`}
-
- );
- } else {
- title = (
-
- {item[titleProp]}
-
- );
- }
-
- return title;
- };
-
- const renderMeta = (item) => {
- // Empty on malformed input — hide the label instead of "Invalid date"
- const updatedAgo = showModified ? timeAgo(item?.modified_at) : "";
- if (!showOwner && !updatedAgo) {
- return null;
- }
- // No click handler here: the co-owner button stops its own propagation,
- // and everything else should bubble to the row like the rest of it
- return (
-
- {showOwner && renderOwnerBadge(item)}
- {updatedAgo && (
-
-
- Updated {updatedAgo}
-
- renderItemMetadata(item)}>
-
-
-
- )}
-
- );
- };
-
- const renderActions = (item) => (
- event.stopPropagation()}
- role="none"
- >
-
- {
- if (item?.is_deprecated) {
- return;
- }
- handleEdit(event, item);
- }}
- className={`action-icon-buttons edit-icon ${
- item?.is_deprecated ? "disabled-icon" : ""
- }`}
- style={{
- cursor: item?.is_deprecated ? "not-allowed" : "pointer",
- opacity: item?.is_deprecated ? 0.4 : 1,
- }}
- />
-
- {handleShare && (
-
- {
- if (item?.is_deprecated) {
- return;
- }
- handleShareClick(event, item, true);
- }}
- style={{
- cursor: item?.is_deprecated ? "not-allowed" : "pointer",
- opacity: item?.is_deprecated ? 0.4 : 1,
- }}
- />
-
- )}
-
}
- onConfirm={(event) => {
- handleDeleteClick(event, item);
- }}
- >
-
-
-
-
-
- );
-
- return (
- {
- return (
- {
- isClickable
- ? navigate(`${item[idProp]}`)
- : handleShareClick(event, item, false);
- }}
- className="cur-pointer"
- >
-
-
- {renderTitle(item)}
- {item[descriptionProp] ? (
-
- {item[descriptionProp]}
-
- ) : null}
-
- {renderMeta(item)}
- {renderActions(item)}
-
-
- );
- }}
- />
- );
-}
-
-ListView.propTypes = {
- listOfTools: PropTypes.array.isRequired,
- handleEdit: PropTypes.func.isRequired,
- handleDelete: PropTypes.func.isRequired,
- handleShare: PropTypes.func,
- handleCoOwner: PropTypes.func,
- titleProp: PropTypes.string.isRequired,
- descriptionProp: PropTypes.string,
- iconProp: PropTypes.string,
- idProp: PropTypes.string.isRequired,
- centered: PropTypes.bool,
- isClickable: PropTypes.bool,
- showOwner: PropTypes.bool,
- showModified: PropTypes.bool,
- type: PropTypes.string,
-};
-
-export { ListView };
diff --git a/frontend/src/components/widgets/resource-table/ResourceTable.css b/frontend/src/components/widgets/resource-table/ResourceTable.css
new file mode 100644
index 0000000000..cbbe1a0af0
--- /dev/null
+++ b/frontend/src/components/widgets/resource-table/ResourceTable.css
@@ -0,0 +1,187 @@
+/* Styles for the sortable resource list table
+ (Name / Owned By / Created / Modified / Actions) */
+
+.resource-table {
+ /* Fill the content width like the design; proportional column widths (set on
+ each column) keep it balanced without one column hogging the slack. */
+ width: 100%;
+ padding: 0 4px 24px 4px;
+}
+
+/* Let wide content scroll inside the table instead of the page body */
+.resource-table .ant-table-content {
+ overflow-x: auto;
+}
+
+/* Keep every cell aligned with the (multi-line) Name cell */
+.resource-table .ant-table-cell {
+ vertical-align: middle;
+}
+
+/* Header row: light, subtle, with uppercase gray labels */
+.resource-table .ant-table-thead > tr > th {
+ background: #fafafa;
+ border-bottom: 1px solid #f0f0f0;
+ padding-top: 14px;
+ padding-bottom: 14px;
+}
+
+/* Sort-dropdown trigger / plain header label */
+.resource-table-th {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: #64748b;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+
+.resource-table-th.static {
+ cursor: default;
+}
+
+.resource-table-th.right {
+ width: 100%;
+ justify-content: flex-end;
+}
+
+.resource-table-th.active {
+ color: #1677ff;
+}
+
+/* Stacked up/down carets — the sortable indicator, blue when active */
+.resource-table-sort-icon {
+ display: inline-flex;
+ flex-direction: column;
+ font-size: 9px;
+ line-height: 0.6;
+ color: #bfbfbf;
+}
+
+.resource-table-th.active .resource-table-sort-icon {
+ color: #1677ff;
+}
+
+.resource-table-row-clickable {
+ cursor: pointer;
+}
+
+/* Name column: icon + stacked title/description */
+.resource-table-name {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+
+.resource-table-name-img {
+ width: 36px;
+ height: 36px;
+ max-width: 36px;
+ object-fit: contain;
+ flex-shrink: 0;
+}
+
+.resource-table-name-emoji {
+ font-size: 20px;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+/* The fixed-width Name column bounds this; min-width:0 lets the title/desc
+ ellipsize within the cell. */
+.resource-table-name-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.resource-table-name-title {
+ font-size: 15px;
+}
+
+.resource-table-name-desc {
+ font-size: 12px;
+}
+
+/* PS-only "Prompts: N" meta line under the project name */
+.resource-table-name-meta {
+ font-size: 12px;
+}
+
+/* Owned By column: avatar + name/email, clickable to manage co-owners */
+.resource-table-owner {
+ min-width: 0;
+}
+
+.resource-table-owner-avatar {
+ font-size: 11px;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.resource-table-owner-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ line-height: 1.3;
+}
+
+.resource-table-owner-name {
+ font-weight: 500;
+ max-width: 190px;
+}
+
+.resource-table-owner-email {
+ font-size: 12px;
+ max-width: 190px;
+}
+
+.resource-table-owner-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ text-align: left;
+ width: 100%;
+}
+
+.resource-table-owner-btn:hover .resource-table-owner-name {
+ color: #1890ff;
+}
+
+/* Actions column */
+.resource-table-actions {
+ justify-content: flex-end;
+}
+
+.resource-table .action-icon-btn {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+}
+
+.resource-table .action-icon-btn[aria-disabled="true"] {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Destructive action is red, per the design */
+.resource-table .delete-icon {
+ color: #ff4d4f;
+}
diff --git a/frontend/src/components/widgets/resource-table/ResourceTable.jsx b/frontend/src/components/widgets/resource-table/ResourceTable.jsx
new file mode 100644
index 0000000000..f2180f939d
--- /dev/null
+++ b/frontend/src/components/widgets/resource-table/ResourceTable.jsx
@@ -0,0 +1,462 @@
+import {
+ CaretDownOutlined,
+ CaretUpOutlined,
+ ClearOutlined,
+ DeleteOutlined,
+ EditOutlined,
+ QuestionCircleOutlined,
+ ShareAltOutlined,
+ SortAscendingOutlined,
+ SortDescendingOutlined,
+} from "@ant-design/icons";
+import {
+ Avatar,
+ Dropdown,
+ Popconfirm,
+ Space,
+ Table,
+ Tooltip,
+ Typography,
+} from "antd";
+import PropTypes from "prop-types";
+import { useNavigate } from "react-router-dom";
+
+import { formattedDateTime, timeAgo } from "../../../helpers/GetStaticData";
+import "./ResourceTable.css";
+
+// Stable, distinct avatar swatch per owner (seeded on email/name) like the
+// design: a light pastel fill paired with a matching darker initial.
+const AVATAR_COLORS = [
+ { bg: "#ffccc7", fg: "#f5222d" },
+ { bg: "#ffe7ba", fg: "#fa8c16" },
+ { bg: "#fff1b8", fg: "#faad14" },
+ { bg: "#d9f7be", fg: "#52c41a" },
+ { bg: "#b5f5ec", fg: "#13c2c2" },
+ { bg: "#bae0ff", fg: "#1677ff" },
+ { bg: "#efdbff", fg: "#722ed1" },
+ { bg: "#ffd6e7", fg: "#eb2f96" },
+];
+const colorForSeed = (seed = "") => {
+ let hash = 0;
+ for (let i = 0; i < seed.length; i += 1) {
+ hash = seed.codePointAt(i) + ((hash << 5) - hash);
+ }
+ return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
+};
+
+// Sort-menu wording differs for text vs date columns (per the design).
+const SORT_OPTIONS = {
+ text: [
+ { key: "asc", label: "A-Z", icon: },
+ { key: "desc", label: "Z-A", icon: },
+ ],
+ date: [
+ { key: "asc", label: "Oldest First", icon: },
+ { key: "desc", label: "Newest First", icon: },
+ ],
+};
+
+/**
+ * Column header with a sort dropdown (A-Z / Z-A / Clear Sort, or Oldest /
+ * Newest First for dates). Server-driven — picking an option refetches.
+ * @return {JSX.Element} Rendered sortable header
+ */
+function SortHeader({
+ label,
+ sortKey,
+ sortType = "text",
+ sort,
+ userSorted,
+ onSortChange,
+}) {
+ // Don't light up the default sort column on load — only once the user picks.
+ const active = userSorted && sort?.sortBy === sortKey;
+ const items = [
+ ...SORT_OPTIONS[sortType],
+ { type: "divider" },
+ { key: "clear", label: "Clear Sort", icon: },
+ ];
+ const onClick = ({ key, domEvent }) => {
+ domEvent?.stopPropagation();
+ if (key === "clear") {
+ onSortChange?.("", "asc");
+ } else {
+ onSortChange?.(sortKey, key);
+ }
+ };
+ return (
+
+
+
+ );
+}
+
+SortHeader.propTypes = {
+ label: PropTypes.string.isRequired,
+ sortKey: PropTypes.string.isRequired,
+ sortType: PropTypes.oneOf(["text", "date"]),
+ sort: PropTypes.object,
+ userSorted: PropTypes.bool,
+ onSortChange: PropTypes.func,
+};
+
+/**
+ * Sortable resource list table (Name / Owned By / Created / Modified / Actions).
+ * Sorting, search and pagination are server-driven — the parent owns the fetch.
+ * @return {JSX.Element} Rendered table
+ */
+function ResourceTable({
+ dataSource,
+ loading,
+ pagination,
+ sort,
+ userSorted,
+ onPaginationChange,
+ onSortChange,
+ titleProp,
+ descriptionProp,
+ iconProp,
+ idProp,
+ dateProp = "created_at",
+ modifiedProp = "modified_at",
+ ownerEmailsProp = "owner_emails",
+ countProp,
+ countLabel,
+ extraColumns = [],
+ handleEdit,
+ handleShare,
+ handleDelete,
+ handleCoOwner,
+ onRowClick,
+ sessionDetails,
+ showOwner = true,
+ isClickable = true,
+ type,
+}) {
+ const navigate = useNavigate();
+
+ const renderName = (item) => {
+ const icon = iconProp ? item?.[iconProp] : null;
+ // Adapters/connectors pass image URLs; Prompt Studio passes emoji. Detect an
+ // actual URL/data source rather than a length heuristic — compound (ZWJ)
+ // emoji exceed 4 UTF-16 units and would otherwise render as a broken
.
+ const isImage =
+ typeof icon === "string" && /^(https?:\/\/|\/|data:image\/)/.test(icon);
+ return (
+
+ {icon &&
+ (isImage ? (
+

+ ) : (
+
{icon}
+ ))}
+
+
+ {item?.[titleProp]}
+
+ {descriptionProp && item?.[descriptionProp] && (
+
+ {item[descriptionProp]}
+
+ )}
+ {countProp && item?.[countProp] != null && (
+
+ {countLabel}: {item[countProp]}
+
+ )}
+
+
+ );
+ };
+
+ const renderOwner = (item) => {
+ // owner_emails is earliest-first; [0] is the primary shown owner.
+ // Fall back to created_by_email so rows with no live OWNER membership
+ // (platform API-key sessions, pre-backfill rows) don't render "Unknown".
+ const ownerEmails = item?.[ownerEmailsProp];
+ const email =
+ (Array.isArray(ownerEmails) ? ownerEmails[0] : undefined) ??
+ item?.created_by_email;
+ // "Me" must track the DISPLAYED owner, not the viewer's own membership —
+ // else a co-owner sees "Me" over the primary owner's avatar/email. Match on
+ // the shown email so the creator viewing their own resource still reads "Me".
+ const isMe = Boolean(email) && email === sessionDetails?.email;
+ const name = isMe ? "Me" : email?.split("@")[0] || "Unknown";
+ const extra =
+ item?.co_owners_count > 1 ? ` +${item.co_owners_count - 1}` : "";
+ const initials = (email || name).slice(0, 2).toUpperCase();
+ const swatch = colorForSeed(email || name);
+ // With co-owners, name only the primary inline (+N); the tooltip lists all
+ // owners so search hitting a hidden co-owner is still explainable.
+ const ownerTooltip =
+ Array.isArray(ownerEmails) && ownerEmails.length > 1
+ ? ownerEmails.join(", ")
+ : `${name}${extra}`;
+
+ const cell = (
+
+
+ {initials}
+
+
+
+ {name}
+ {extra}
+
+ {email && email !== name && (
+
+ {email}
+
+ )}
+
+
+ );
+
+ if (!handleCoOwner) {
+ return cell;
+ }
+ return (
+
+
+
+ );
+ };
+
+ const renderActions = (item) => {
+ const deprecated = item?.is_deprecated;
+ const disabledTitle = deprecated ? "This adapter is deprecated" : "";
+ return (
+ event.stopPropagation()}
+ role="none"
+ >
+
+
+
+ {handleShare && (
+
+
+
+ )}
+ }
+ onConfirm={(event) => handleDelete?.(event, item)}
+ >
+
+
+
+ );
+ };
+
+ const columns = [
+ {
+ title: (
+
+ ),
+ key: "name",
+ width: "34%",
+ render: (_, item) => renderName(item),
+ },
+ showOwner && {
+ title: Owned By,
+ key: "owner",
+ width: "22%",
+ render: (_, item) => renderOwner(item),
+ },
+ {
+ title: (
+
+ ),
+ key: "created",
+ width: "15%",
+ render: (_, item) => formattedDateTime(item?.[dateProp]) || "-",
+ },
+ {
+ title: (
+
+ ),
+ key: "modified",
+ width: "15%",
+ render: (_, item) => {
+ const iso = item?.[modifiedProp];
+ const rel = timeAgo(iso);
+ return rel ? (
+ {rel}
+ ) : (
+ "-"
+ );
+ },
+ },
+ // Resource-specific columns (e.g. Files, Latest Version) sit between the
+ // shared date columns and Actions.
+ ...extraColumns,
+ {
+ title: Actions,
+ key: "actions",
+ width: "14%",
+ align: "right",
+ render: (_, item) => renderActions(item),
+ },
+ ].filter(Boolean);
+
+ // Sorting is handled by the header dropdowns, so onChange only carries the
+ // pager here.
+ const handleChange = (paginationConf) => {
+ onPaginationChange?.(paginationConf.current, paginationConf.pageSize);
+ };
+
+ return (
+ ({
+ // onRowClick lets callers override the default relative nav (e.g. an
+ // absolute org-scoped path with router state).
+ onClick: isClickable
+ ? () =>
+ onRowClick ? onRowClick(item) : navigate(`${item?.[idProp]}`)
+ : undefined,
+ })}
+ pagination={{
+ current: pagination?.current,
+ pageSize: pagination?.pageSize,
+ total: pagination?.total,
+ showSizeChanger: false,
+ showTotal: (total) =>
+ `Page ${pagination?.current} of ${Math.max(
+ 1,
+ Math.ceil(total / (pagination?.pageSize || 1)),
+ )} · ${total} items`,
+ }}
+ />
+ );
+}
+
+ResourceTable.propTypes = {
+ dataSource: PropTypes.array,
+ loading: PropTypes.bool,
+ pagination: PropTypes.object,
+ sort: PropTypes.object,
+ userSorted: PropTypes.bool,
+ onPaginationChange: PropTypes.func,
+ onSortChange: PropTypes.func,
+ titleProp: PropTypes.string.isRequired,
+ descriptionProp: PropTypes.string,
+ iconProp: PropTypes.string,
+ idProp: PropTypes.string.isRequired,
+ dateProp: PropTypes.string,
+ modifiedProp: PropTypes.string,
+ ownerEmailsProp: PropTypes.string,
+ countProp: PropTypes.string,
+ countLabel: PropTypes.string,
+ extraColumns: PropTypes.array,
+ handleEdit: PropTypes.func,
+ handleShare: PropTypes.func,
+ handleDelete: PropTypes.func,
+ handleCoOwner: PropTypes.func,
+ onRowClick: PropTypes.func,
+ sessionDetails: PropTypes.object,
+ showOwner: PropTypes.bool,
+ isClickable: PropTypes.bool,
+ type: PropTypes.string,
+};
+
+export { ResourceTable };
diff --git a/frontend/src/components/workflows/workflow/Workflows.css b/frontend/src/components/workflows/workflow/Workflows.css
index 7a407a2fec..f72d8001d1 100644
--- a/frontend/src/components/workflows/workflow/Workflows.css
+++ b/frontend/src/components/workflows/workflow/Workflows.css
@@ -67,13 +67,6 @@
width: fit-content;
}
-.listWrapper {
- height: 92%;
- overflow: hidden auto;
- margin-block-start: 12px;
- margin-inline: 16px;
-}
-
.listItem {
display: grid;
grid-auto-flow: row;
diff --git a/frontend/src/components/workflows/workflow/Workflows.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx
index 7d5567c36f..4cc41b86c0 100644
--- a/frontend/src/components/workflows/workflow/Workflows.jsx
+++ b/frontend/src/components/workflows/workflow/Workflows.jsx
@@ -1,12 +1,16 @@
import { PlusOutlined, UserOutlined } from "@ant-design/icons";
-import { Pagination, Typography } from "antd";
+import { Typography } from "antd";
import PropTypes from "prop-types";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { 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 {
+ applyPagedResponse,
+ buildPagedParams,
+ usePaginatedList,
+} from "../../../hooks/usePaginatedList";
import usePostHogEvents from "../../../hooks/usePostHogEvents.js";
import {
useInitialFetchCount,
@@ -18,13 +22,13 @@ import { useSessionStore } from "../../../store/session-store";
import { useWorkflowStore } from "../../../store/workflow-store";
import { usePromptStudioService } from "../../api/prompt-studio-service";
import { PromptStudioModal } from "../../common/PromptStudioModal";
-import { ViewTools } from "../../custom-tools/view-tools/ViewTools.jsx";
import { groupsService } from "../../groups/groups-service.js";
import { ToolNavBar } from "../../navigations/tool-nav-bar/ToolNavBar.jsx";
-import { CoOwnerManagement } from "../../widgets/co-owner-management/CoOwnerManagement.jsx";
+import { CoOwnerModal } from "../../widgets/co-owner-management/CoOwnerModal.jsx";
import { CustomButton } from "../../widgets/custom-button/CustomButton.jsx";
import { EmptyState } from "../../widgets/empty-state/EmptyState.jsx";
import { LazyLoader } from "../../widgets/lazy-loader/LazyLoader.jsx";
+import { ResourceTable } from "../../widgets/resource-table/ResourceTable.jsx";
import { SharePermission } from "../../widgets/share-permission/SharePermission.jsx";
import { SpinnerLoader } from "../../widgets/spinner-loader/SpinnerLoader.jsx";
import { workflowService } from "./workflow-service";
@@ -50,11 +54,16 @@ function Workflows() {
);
const [projectList, setProjectList] = useState();
+ // Fetch failure (vs. genuinely empty) — drives a retryable error state.
+ const [loadError, setLoadError] = useState(false);
const [editingProject, setEditProject] = useState();
const [loading, setLoading] = useState(false);
+ // Modal-local save spinner — kept off the shared list-loading so an edit can't
+ // race the post-edit refetch for the list's loading state.
+ const [editLoading, setEditLoading] = useState(false);
const [openModal, toggleModal] = useState(true);
- // Ref forwards the fetch fn to the pagination hook (avoids declaration ordering)
- const fetchListRef = useRef(null);
+ // Monotonic request token so a stale response can't overwrite a newer one.
+ const seqRef = useRef(0);
const [backendErrors, setBackendErrors] = useState(null);
const [shareOpen, setShareOpen] = useState(false);
const [selectedWorkflow, setSelectedWorkflow] = useState();
@@ -69,34 +78,21 @@ function Workflows() {
pagination,
setPagination,
searchTerm,
+ sort,
+ userSorted,
+ fetchRef,
+ requestList,
+ syncRequested,
handlePaginationChange,
handleSearch,
+ handleSortChange,
+ handleListRefresh,
} = usePaginatedList({
- fetchData: (...args) => fetchListRef.current?.(...args),
defaultPageSize: DEFAULT_PAGE_SIZE,
+ defaultSortBy: "modified_at",
+ defaultOrder: "desc",
});
-
- // Refresh the current page (preserves page + active search) after mutations
- const handleListRefresh = useCallback(
- () =>
- fetchListRef.current?.(
- pagination.current,
- pagination.pageSize,
- searchTerm,
- ),
- [pagination.current, pagination.pageSize, searchTerm],
- );
- const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ const coOwner = useCoOwnerManagement({
service: projectApiService,
setAlertDetails,
onListRefresh: handleListRefresh,
@@ -107,7 +103,7 @@ function Workflows() {
useEffect(() => {
if (location.pathname === `/${orgName}/workflows`) {
- getProjectList();
+ requestList(1, DEFAULT_PAGE_SIZE, "", sort.sortBy, sort.order);
}
}, [location.pathname]);
@@ -115,46 +111,53 @@ function Workflows() {
page = 1,
pageSize = DEFAULT_PAGE_SIZE,
search = "",
+ sortBy = "",
+ order = "asc",
) => {
+ const params = buildPagedParams({ page, pageSize, search, sortBy, order });
+ const seq = ++seqRef.current;
+ setLoadError(false);
setLoading(true);
- const params = { page, page_size: pageSize };
- if (search) {
- params.search = search;
- }
- projectApiService
+ return projectApiService
.getProjectList(params)
- .then((res) => {
- 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 ?? [];
- 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);
+ .then((res) =>
+ applyPagedResponse({
+ data: res?.data,
+ page,
+ pageSize,
+ seq,
+ latestSeqRef: seqRef,
+ setList: setProjectList,
+ setPagination,
+ refetchPrevPage: () =>
+ requestList(page - 1, pageSize, search, sortBy, order),
+ }),
+ )
+ .catch((err) => {
+ // A newer request superseded this one — don't surface its error.
+ if (seq !== seqRef.current) {
return;
}
- setProjectList(results);
- setPagination((prev) => ({
- ...prev,
- current: page,
- pageSize,
- total,
- }));
- })
- .catch(() => {
- console.error("Unable to get project list");
- // Avoid an indefinite spinner when the first fetch fails.
- setProjectList((prev) => prev ?? []);
+ setAlertDetails(handleException(err, "Unable to load workflows"));
+ // Surface a retryable error instead of a misleading empty state.
+ setLoadError(true);
+ // Failed request — realign requestedRef with the still-shown view.
+ syncRequested();
})
.finally(() => {
- setLoading(false);
+ // Only the newest request owns the shared loading state.
+ if (seq === seqRef.current) {
+ setLoading(false);
+ }
});
};
- fetchListRef.current = getProjectList;
+ fetchRef.current = getProjectList;
function editProject(name, description) {
- setLoading(true);
+ // Drive the modal-local editLoading, not the shared list-loading: on success
+ // the edit path's handleListRefresh owns the list spinner (new path navigates
+ // away), so editProject can't clear a pending refetch's loading.
+ setEditLoading(true);
projectApiService
.editProject(name, description, editingProject?.id)
.then((res) => {
@@ -178,7 +181,7 @@ function Workflows() {
);
})
.finally(() => {
- setLoading(false);
+ setEditLoading(false);
});
}
@@ -360,7 +363,7 @@ function Workflows() {
const handleCoOwner = (event, workflow) => {
event.stopPropagation();
- handleCoOwnerAction(workflow.id);
+ coOwner.handleCoOwner(workflow.id);
};
const handleNewWorkflowBtnClick = () => {
@@ -401,13 +404,21 @@ function Workflows() {
handleSearch(value)}
/>
- {projectList === undefined &&
}
- {projectList?.length === 0 && !searchTerm && (
+ {loadError && (
+
+ )}
+ {!loadError && projectList === undefined &&
}
+ {!loadError && projectList?.length === 0 && !searchTerm && (
)}
- {projectList?.length === 0 && searchTerm && (
+ {!loadError && projectList?.length === 0 && searchTerm && (
)}
- {projectList?.length > 0 && (
- <>
-
- {pagination.total > pagination.pageSize && (
-
- )}
- >
+ {!loadError && projectList?.length > 0 && (
+
)}
{editingProject && (
)}
- {coOwnerOpen && (
-
+ {coOwner.coOwnerOpen && (
+
)}
diff --git a/frontend/src/components/workflows/workflow/workflow-service.js b/frontend/src/components/workflows/workflow/workflow-service.js
index 9453aedc46..ed6c430675 100644
--- a/frontend/src/components/workflows/workflow/workflow-service.js
+++ b/frontend/src/components/workflows/workflow/workflow-service.js
@@ -1,3 +1,4 @@
+import { fetchAllPages } from "../../../helpers/pagination.js";
import { useAxiosPrivate } from "../../../hooks/useAxiosPrivate.js";
import { useSessionStore } from "../../../store/session-store.js";
@@ -10,13 +11,12 @@ function workflowService() {
const csrfToken = sessionDetails.csrfToken;
return {
- getWorkflowList: () => {
- options = {
- url: `${path}/workflow/?is_active=True`,
- method: "GET",
- };
- return axiosPrivate(options);
- },
+ // Feeds selectors, so it resolves to every workflow rather than one page.
+ getWorkflowList: () =>
+ fetchAllPages(axiosPrivate, {
+ url: `${path}/workflow/`,
+ params: { is_active: "True" },
+ }),
getWorkflowEndpointList: (endpointType, connectorType) => {
options = {
url: `${path}/workflow/endpoint/?endpoint_type=${endpointType}&connection_type=${connectorType}`,
diff --git a/frontend/src/helpers/pagination.js b/frontend/src/helpers/pagination.js
new file mode 100644
index 0000000000..939e55dff0
--- /dev/null
+++ b/frontend/src/helpers/pagination.js
@@ -0,0 +1,74 @@
+/**
+ * Helpers for list endpoints that may or may not be paginated.
+ *
+ * The shared resource endpoints (workflows, prompt studio, adapters,
+ * connectors) return a bare array unless the caller opts into pagination, and
+ * a `{count, next, previous, results}` envelope once it does. Callers should
+ * not care which they got.
+ */
+
+/**
+ * Rows out of a list response, whichever shape it arrived in.
+ *
+ * @param {Object} res - axios response
+ * @return {Array} the rows, or [] when the payload is neither shape
+ */
+function unwrapList(res) {
+ const data = res?.data;
+ if (Array.isArray(data)) {
+ return data;
+ }
+ return Array.isArray(data?.results) ? data.results : [];
+}
+
+/**
+ * Every row of a list endpoint, following pagination to exhaustion.
+ *
+ * For selectors and dropdowns, which must show the full set — reading only the
+ * first page would silently hide entries with no error. Pages are requested by
+ * number rather than by following `next`, so the call keeps going through the
+ * caller's axios instance (base URL, auth interceptors) instead of a
+ * server-built absolute URL.
+ *
+ * @param {Object} axiosInstance - an axios instance (e.g. from useAxiosPrivate)
+ * @param {Object} config - axios request config; `url` is required
+ * @return {Promise} all rows across all pages
+ */
+// Mirrors backend Pagination.MAX_PAGE_SIZE. Requesting the max up front keeps
+// the common case (an org below this many rows) to a single round-trip once the
+// endpoints paginate by default; the loop stays as the tail guard past it.
+const MAX_PAGE_SIZE = 1000;
+
+async function fetchAllPages(axiosInstance, config) {
+ const request = (params) =>
+ axiosInstance({ ...config, method: "GET", ...(params ? { params } : {}) });
+
+ const first = await request({
+ ...(config?.params ?? {}),
+ page_size: MAX_PAGE_SIZE,
+ });
+ if (Array.isArray(first?.data)) {
+ return first.data;
+ }
+
+ const rows = [...(first?.data?.results ?? [])];
+ const total = first?.data?.count ?? rows.length;
+ let page = 1;
+ while (rows.length < total) {
+ page += 1;
+ const res = await request({
+ ...(config?.params ?? {}),
+ page,
+ page_size: MAX_PAGE_SIZE,
+ });
+ const nextRows = res?.data?.results ?? [];
+ // A page that adds nothing would otherwise spin forever on a bad count.
+ if (!nextRows.length) {
+ break;
+ }
+ rows.push(...nextRows);
+ }
+ return rows;
+}
+
+export { fetchAllPages, unwrapList };
diff --git a/frontend/src/helpers/pagination.test.js b/frontend/src/helpers/pagination.test.js
new file mode 100644
index 0000000000..64810137e0
--- /dev/null
+++ b/frontend/src/helpers/pagination.test.js
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { fetchAllPages, unwrapList } from "./pagination";
+
+describe("unwrapList", () => {
+ it("returns a bare array unchanged", () => {
+ expect(unwrapList({ data: [1, 2] })).toEqual([1, 2]);
+ });
+
+ it("unwraps a paginated envelope", () => {
+ expect(unwrapList({ data: { count: 2, results: [1, 2] } })).toEqual([1, 2]);
+ });
+
+ it("returns [] for anything else", () => {
+ expect(unwrapList(undefined)).toEqual([]);
+ expect(unwrapList({ data: { detail: "nope" } })).toEqual([]);
+ });
+});
+
+describe("fetchAllPages", () => {
+ it("makes a single request when the endpoint is not paginated", async () => {
+ const axiosInstance = vi.fn().mockResolvedValue({ data: [1, 2, 3] });
+ await expect(fetchAllPages(axiosInstance, { url: "/x/" })).resolves.toEqual(
+ [1, 2, 3],
+ );
+ expect(axiosInstance).toHaveBeenCalledTimes(1);
+ });
+
+ it("follows pages until every row is collected", async () => {
+ const axiosInstance = vi
+ .fn()
+ .mockResolvedValueOnce({ data: { count: 3, results: [1, 2] } })
+ .mockResolvedValueOnce({ data: { count: 3, results: [3] } });
+
+ await expect(
+ fetchAllPages(axiosInstance, { url: "/x/", params: { type: "LLM" } }),
+ ).resolves.toEqual([1, 2, 3]);
+ // Later pages must keep the caller's own params alongside ?page.
+ expect(axiosInstance.mock.calls[1][0].params).toEqual({
+ type: "LLM",
+ page: 2,
+ });
+ });
+
+ it("stops instead of looping when a page adds nothing", async () => {
+ const axiosInstance = vi
+ .fn()
+ .mockResolvedValue({ data: { count: 99, results: [] } });
+ await expect(fetchAllPages(axiosInstance, { url: "/x/" })).resolves.toEqual(
+ [],
+ );
+ expect(axiosInstance).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/frontend/src/hooks/useListSearch.js b/frontend/src/hooks/useListSearch.js
deleted file mode 100644
index f74d96718c..0000000000
--- a/frontend/src/hooks/useListSearch.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { useCallback, useRef, useState } from "react";
-
-function useListSearch(searchField) {
- const listRef = useRef([]);
- const searchTextRef = useRef("");
- const [displayList, setDisplayList] = useState([]);
-
- const filterList = useCallback(
- (list, searchText) => {
- if (!searchText.trim()) {
- return list;
- }
- return list.filter((item) =>
- item[searchField]?.toLowerCase().includes(searchText.toLowerCase()),
- );
- },
- [searchField],
- );
-
- const setMasterList = useCallback(
- (list) => {
- listRef.current = list;
- setDisplayList(filterList(list, searchTextRef.current));
- },
- [filterList],
- );
-
- const onSearch = useCallback(
- (searchText, setSearchList) => {
- searchTextRef.current = searchText;
- setSearchList(filterList(listRef.current, searchText));
- },
- [filterList],
- );
-
- const clearSearch = useCallback(() => {
- searchTextRef.current = "";
- setDisplayList(listRef.current);
- }, []);
-
- const updateMasterList = useCallback(
- (updateFn) => {
- const updatedList = updateFn(listRef.current);
- listRef.current = updatedList;
- setDisplayList(filterList(updatedList, searchTextRef.current));
- },
- [filterList],
- );
-
- return {
- listRef,
- displayList,
- setDisplayList,
- setMasterList,
- updateMasterList,
- onSearch,
- clearSearch,
- };
-}
-
-export { useListSearch };
diff --git a/frontend/src/hooks/usePaginatedList.js b/frontend/src/hooks/usePaginatedList.js
index ce45ffa3f4..f90653ab81 100644
--- a/frontend/src/hooks/usePaginatedList.js
+++ b/frontend/src/hooks/usePaginatedList.js
@@ -1,44 +1,213 @@
-import { useRef, useState } from "react";
+import { useCallback, useRef, useState } from "react";
+
+/**
+ * Build the query params for a paginated list request, omitting empty
+ * search/sort so non-paginated callers stay unaffected. Callers add any
+ * resource-specific params (e.g. `adapter_type`) to the returned object.
+ *
+ * @param {Object} args
+ * @param {number} args.page - Requested page.
+ * @param {number} args.pageSize - Requested page size.
+ * @param {string} [args.search] - Search term.
+ * @param {string} [args.sortBy] - Sort column key.
+ * @param {string} [args.order] - Sort direction.
+ * @return {Object} Query params.
+ */
+function buildPagedParams({ page, pageSize, search, sortBy, order }) {
+ const params = { page, page_size: pageSize };
+ if (search) {
+ params.search = search;
+ }
+ if (sortBy) {
+ // DRF OrderingFilter: `?ordering=field`, a leading `-` sorts descending.
+ params.ordering = (order === "desc" ? "-" : "") + sortBy;
+ }
+ return params;
+}
+
+/**
+ * Commit a paginated list response, shared by every resource list page.
+ *
+ * Unwraps the opt-in envelope (`{results, count}` or a bare array), drops stale
+ * responses that a newer request already superseded, and steps back a page when
+ * a delete empties the last one — returning that refetch promise so the caller's
+ * `finally` waits for replacement data instead of clearing loading early.
+ *
+ * @param {Object} args
+ * @param {*} args.data - `res.data`: envelope or bare array.
+ * @param {number} args.page - Requested page.
+ * @param {number} args.pageSize - Requested page size.
+ * @param {number} args.seq - This request's sequence token.
+ * @param {{current: number}} args.latestSeqRef - Ref holding the newest token.
+ * @param {Function} args.setList - List state setter.
+ * @param {Function} args.setPagination - Pagination state setter.
+ * @param {Function} args.refetchPrevPage - Refetches `page - 1`; its promise is
+ * returned so the caller's `finally` waits for it.
+ * @return {Promise|undefined}
+ */
+function applyPagedResponse({
+ data,
+ page,
+ pageSize,
+ seq,
+ latestSeqRef,
+ setList,
+ setPagination,
+ refetchPrevPage,
+}) {
+ // A newer request already fired — ignore this superseded response.
+ if (seq !== latestSeqRef.current) {
+ return undefined;
+ }
+ // A 204 body or an unexpected object must not reach antd Table as dataSource.
+ const raw = data?.results ?? data;
+ const results = Array.isArray(raw) ? raw : [];
+ 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) {
+ return refetchPrevPage();
+ }
+ setList(results);
+ setPagination((prev) => ({ ...prev, current: page, pageSize, total }));
+ return undefined;
+}
/**
* Shared hook for paginated list state and handlers.
- * Uses a ref internally to avoid stale closure issues with fetchData.
*
- * @param {Object} options
- * @param {Function} options.fetchData - fn(page, pageSize, search) to fetch data
+ * Owns `fetchRef` — the page assigns its fetch fn to `fetchRef.current`, and the
+ * hook's handlers (search/sort/paginate) plus `handleListRefresh` invoke the
+ * latest one, so pages don't re-derive that wiring. `handleListRefresh` refetches
+ * the current page preserving active search/sort — pass it to mutation callbacks.
+ *
+ * @param {Object} [options]
* @param {number} [options.defaultPageSize=10]
- * @return {Object} Pagination state and handlers
+ * @param {string} [options.defaultSortBy=""] - initial sort column key
+ * @param {string} [options.defaultOrder="asc"] - initial sort direction
+ * @return {Object} Pagination/sort state, `fetchRef`, and handlers.
*/
-function usePaginatedList({ fetchData, defaultPageSize = 10 }) {
+function usePaginatedList({
+ defaultPageSize = 10,
+ defaultSortBy = "",
+ defaultOrder = "asc",
+} = {}) {
const [pagination, setPagination] = useState({
current: 1,
pageSize: defaultPageSize,
total: 0,
});
const [searchTerm, setSearchTerm] = useState("");
+ const [sort, setSort] = useState({
+ sortBy: defaultSortBy,
+ order: defaultOrder,
+ });
+ // The default sort still drives the server; this only gates the header's
+ // "active" styling so the default column isn't highlighted until the user
+ // explicitly picks a sort.
+ const [userSorted, setUserSorted] = useState(false);
- const fetchRef = useRef(fetchData);
- fetchRef.current = fetchData;
+ // Page assigns its fetch fn here; handlers call the latest one via the ref.
+ const fetchRef = useRef(null);
+
+ // page/pageSize mirror the applied view; search/sort are last-REQUESTED
+ // (handlers commit before firing). syncRequested snapshots this for refresh.
+ const appliedRef = useRef(null);
+ appliedRef.current = {
+ page: pagination.current,
+ pageSize: pagination.pageSize,
+ search: searchTerm,
+ sortBy: sort.sortBy,
+ order: sort.order,
+ };
+
+ // Params of the last request, recorded at fetch time (state lags applies).
+ // handleListRefresh replays these so a refresh targets the asked-for view.
+ const requestedRef = useRef({
+ page: 1,
+ pageSize: defaultPageSize,
+ search: "",
+ sortBy: defaultSortBy,
+ order: defaultOrder,
+ });
+
+ // Single fetch entry: records the requested view before firing so every path
+ // (navigation, stepback, reset) keeps requestedRef in sync. Returns the fetch
+ // promise so a stepback propagates up through applyPagedResponse.
+ const requestList = (page, pageSize, search, sortBy, order) => {
+ requestedRef.current = { page, pageSize, search, sortBy, order };
+ return fetchRef.current?.(page, pageSize, search, sortBy, order);
+ };
const handlePaginationChange = (page, pageSize) => {
const newPage = pageSize === pagination.pageSize ? page : 1;
- fetchRef.current?.(newPage, pageSize, searchTerm);
+ requestList(newPage, pageSize, searchTerm, sort.sortBy, sort.order);
};
const handleSearch = (searchText) => {
const term = searchText?.trim() || "";
setSearchTerm(term);
- fetchRef.current?.(1, pagination.pageSize, term);
+ requestList(1, pagination.pageSize, term, sort.sortBy, sort.order);
};
+ // Table header sort click: reset to page 1 (the page a row sits on changes)
+ // and refetch with the new ordering.
+ const handleSortChange = (sortBy, order) => {
+ // "Clear Sort" (empty sortBy) restores the default view, not an empty
+ // ordering: the viewset always applies one, so header and rows stay in sync.
+ const nextSort = sortBy
+ ? { sortBy, order: order || "asc" }
+ : { sortBy: defaultSortBy, order: defaultOrder };
+ setSort(nextSort);
+ setUserSorted(Boolean(sortBy));
+ requestList(
+ 1,
+ pagination.pageSize,
+ searchTerm,
+ nextSort.sortBy,
+ nextSort.order,
+ );
+ };
+
+ // Reset to a fresh default view (state + requestedRef) and fetch — used when
+ // a persistent instance changes what it lists (e.g. ToolSettings adapter type)
+ // so search box, sort header, and requestedRef all start clean.
+ const resetList = () => {
+ setSearchTerm("");
+ setSort({ sortBy: defaultSortBy, order: defaultOrder });
+ setUserSorted(false);
+ requestList(1, defaultPageSize, "", defaultSortBy, defaultOrder);
+ };
+
+ // Realign requestedRef with the displayed view — call from a page's catch when
+ // its newest request failed (setList was skipped, so the screen still shows the
+ // previous view). Stops the next handleListRefresh from replaying the failed
+ // target (e.g. jumping to a page whose fetch errored).
+ const syncRequested = () => {
+ requestedRef.current = { ...appliedRef.current };
+ };
+
+ // Stable identity; replays the last requested params.
+ const handleListRefresh = useCallback(() => {
+ const { page, pageSize, search, sortBy, order } = requestedRef.current;
+ fetchRef.current?.(page, pageSize, search, sortBy, order);
+ }, []);
+
return {
pagination,
setPagination,
searchTerm,
setSearchTerm,
+ sort,
+ userSorted,
+ fetchRef,
+ requestList,
+ resetList,
+ syncRequested,
handlePaginationChange,
handleSearch,
+ handleSortChange,
+ handleListRefresh,
};
}
-export { usePaginatedList };
+export { applyPagedResponse, buildPagedParams, usePaginatedList };
diff --git a/frontend/src/pages/ConnectorsPage.jsx b/frontend/src/pages/ConnectorsPage.jsx
index cacb50fc27..5e95351823 100644
--- a/frontend/src/pages/ConnectorsPage.jsx
+++ b/frontend/src/pages/ConnectorsPage.jsx
@@ -1,22 +1,30 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button } from "antd";
-import { useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { ViewTools } from "../components/custom-tools/view-tools/ViewTools";
import { groupsService } from "../components/groups/groups-service.js";
import { AddSourceModal } from "../components/input-output/add-source-modal/AddSourceModal";
import { ToolNavBar } from "../components/navigations/tool-nav-bar/ToolNavBar";
-import { CoOwnerManagement } from "../components/widgets/co-owner-management/CoOwnerManagement";
+import { CoOwnerModal } from "../components/widgets/co-owner-management/CoOwnerModal";
+import { EmptyState } from "../components/widgets/empty-state/EmptyState.jsx";
+import { ResourceTable } from "../components/widgets/resource-table/ResourceTable";
import { SharePermission } from "../components/widgets/share-permission/SharePermission";
+import { SpinnerLoader } from "../components/widgets/spinner-loader/SpinnerLoader.jsx";
import { useAxiosPrivate } from "../hooks/useAxiosPrivate";
import { useCoOwnerManagement } from "../hooks/useCoOwnerManagement";
import { useExceptionHandler } from "../hooks/useExceptionHandler";
-import { useListSearch } from "../hooks/useListSearch";
+import {
+ applyPagedResponse,
+ buildPagedParams,
+ usePaginatedList,
+} from "../hooks/usePaginatedList";
import useRequestUrl from "../hooks/useRequestUrl";
import { useAlertStore } from "../store/alert-store";
import { useSessionStore } from "../store/session-store";
import "./ConnectorsPage.css";
+const DEFAULT_PAGE_SIZE = 10;
+
function ConnectorsPage() {
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
@@ -27,6 +35,10 @@ function ConnectorsPage() {
const [groupList, setGroupList] = useState([]);
const [isPermissionEdit, setIsPermissionEdit] = useState(false);
const [isShareLoading, setIsShareLoading] = useState(false);
+ // undefined = not fetched yet (spinner); [] = fetched-empty (empty state)
+ const [displayList, setDisplayList] = useState();
+ // Fetch failure (vs. genuinely empty) — drives a retryable error state.
+ const [loadError, setLoadError] = useState(false);
const groupsApi = groupsService();
const axiosPrivate = useAxiosPrivate();
@@ -34,6 +46,8 @@ function ConnectorsPage() {
const { setAlertDetails } = useAlertStore();
const handleException = useExceptionHandler();
const { getUrl } = useRequestUrl();
+ // Monotonic request token so a stale response can't overwrite a newer one.
+ const seqRef = useRef(0);
const connectorCoOwnerService = useMemo(
() => ({
@@ -62,40 +76,91 @@ function ConnectorsPage() {
);
const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ pagination,
+ setPagination,
+ searchTerm,
+ sort,
+ userSorted,
+ fetchRef,
+ requestList,
+ syncRequested,
+ handlePaginationChange,
+ handleSearch,
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({
+ defaultPageSize: DEFAULT_PAGE_SIZE,
+ defaultSortBy: "modified_at",
+ defaultOrder: "desc",
+ });
+
+ const coOwner = useCoOwnerManagement({
service: connectorCoOwnerService,
setAlertDetails,
- onListRefresh: () => fetchConnectors(),
+ onListRefresh: handleListRefresh,
});
- const { listRef, displayList, setDisplayList, setMasterList, onSearch } =
- useListSearch("connector_name");
+
+ const getConnectors = useCallback(
+ (
+ page = 1,
+ pageSize = DEFAULT_PAGE_SIZE,
+ search = "",
+ sortBy = "",
+ order = "asc",
+ ) => {
+ const params = buildPagedParams({
+ page,
+ pageSize,
+ search,
+ sortBy,
+ order,
+ });
+ const seq = ++seqRef.current;
+ setLoadError(false);
+ setLoading(true);
+ return axiosPrivate
+ .get(getUrl("connector/"), { params })
+ .then((res) =>
+ applyPagedResponse({
+ data: res?.data,
+ page,
+ pageSize,
+ seq,
+ latestSeqRef: seqRef,
+ setList: setDisplayList,
+ setPagination,
+ refetchPrevPage: () =>
+ requestList(page - 1, pageSize, search, sortBy, order),
+ }),
+ )
+ .catch((err) => {
+ // A newer request superseded this one — don't surface its error.
+ if (seq !== seqRef.current) {
+ return;
+ }
+ setAlertDetails(handleException(err, "Failed to load connectors"));
+ // Surface a retryable error instead of a misleading empty state.
+ setLoadError(true);
+ // Failed request — realign requestedRef with the still-shown view.
+ syncRequested();
+ })
+ .finally(() => {
+ // Only the newest request owns the shared loading state.
+ if (seq === seqRef.current) {
+ setLoading(false);
+ }
+ });
+ },
+ [axiosPrivate, getUrl, setPagination, setAlertDetails, handleException],
+ );
+ fetchRef.current = getConnectors;
useEffect(() => {
- fetchConnectors();
+ requestList(1, DEFAULT_PAGE_SIZE, "", sort.sortBy, sort.order);
fetchUsers();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const fetchConnectors = async () => {
- setLoading(true);
- try {
- const response = await axiosPrivate.get(getUrl("connector/"));
- setMasterList(response.data || []);
- } catch (error) {
- setAlertDetails(handleException(error, "Failed to load connectors"));
- } finally {
- setLoading(false);
- }
- };
-
const fetchUsers = async () => {
try {
const response = await axiosPrivate.get(getUrl("users/"));
@@ -134,7 +199,7 @@ function ConnectorsPage() {
type: "success",
content: "Connector deleted successfully",
});
- fetchConnectors();
+ handleListRefresh();
} catch (error) {
setAlertDetails(handleException(error, "Failed to delete connector"));
}
@@ -204,14 +269,18 @@ function ConnectorsPage() {
};
const handleCoOwner = (_event, connector) => {
- if (!connector?.id) return;
- handleCoOwnerAction(connector.id);
+ if (!connector?.id) {
+ return;
+ }
+ coOwner.handleCoOwner(connector.id);
};
const handleConnectorSaved = () => {
setModalVisible(false);
setEditingConnector(null);
- fetchConnectors();
+ // New/edited connectors land on some page under the active sort — refetch
+ // the current page to reflect server truth rather than splicing a stale array.
+ handleListRefresh();
setAlertDetails({
type: "success",
content: editingConnector
@@ -235,30 +304,53 @@ function ConnectorsPage() {
handleSearch(value)}
customButtons={newConnectorButton}
/>
-
+ {loadError && (
+
+ )}
+ {!loadError && displayList === undefined && }
+ {!loadError && displayList?.length === 0 && !searchTerm && (
+
+ )}
+ {!loadError && displayList?.length === 0 && searchTerm && (
+
+ )}
+ {!loadError && displayList?.length > 0 && (
+
+ )}
-
+
);
}