diff --git a/backend/adapter_processor_v2/serializers.py b/backend/adapter_processor_v2/serializers.py
index a5f2c492d6..34aeccc5c1 100644
--- a/backend/adapter_processor_v2/serializers.py
+++ b/backend/adapter_processor_v2/serializers.py
@@ -215,6 +215,7 @@ def to_representation(self, instance: AdapterInstance) -> 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_email"] = instance.owner_email()
return rep
diff --git a/backend/adapter_processor_v2/views.py b/backend/adapter_processor_v2/views.py
index 7dfacf4434..cb2fedfd62 100644
--- a/backend/adapter_processor_v2/views.py
+++ b/backend/adapter_processor_v2/views.py
@@ -28,6 +28,7 @@
from tenant_account_v2.organization_member_service import OrganizationMemberService
from tool_instance_v2.models import ToolInstance
from utils.filtering import FilterHelper
+from utils.list_query import apply_search_and_sort
from utils.pagination import OptionalPagination
from utils.user_context import UserContext
@@ -190,14 +191,16 @@ def get_queryset(self) -> QuerySet | None:
):
queryset = queryset.filter(**filter_args)
- search = self.request.query_params.get("search")
- if search:
- queryset = queryset.filter(adapter_name__icontains=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")
+ # Owner-inclusive search + per-column sort (name/owner/created); re-wraps
+ # via pk__in to drop the DISTINCT ON in for_user() so any column sorts.
+ return apply_search_and_sort(
+ queryset,
+ model=AdapterInstance,
+ name_field="adapter_name",
+ request=self.request,
+ select_related=("created_by",),
+ prefetch_related=("memberships__user",),
+ )
def get_serializer_class(
self,
diff --git a/backend/connector_v2/serializers.py b/backend/connector_v2/serializers.py
index 5c4c158333..7d15edb7f0 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_email"] = instance.owner_email()
return rep
diff --git a/backend/connector_v2/views.py b/backend/connector_v2/views.py
index c4a3741f25..a2d5ca5810 100644
--- a/backend/connector_v2/views.py
+++ b/backend/connector_v2/views.py
@@ -21,6 +21,7 @@
from rest_framework.versioning import URLPathVersioning
from tenant_account_v2.organization_member_service import OrganizationMemberService
from utils.filtering import FilterHelper
+from utils.list_query import apply_search_and_sort
from utils.pagination import OptionalPagination
from utils.user_context import UserContext
@@ -105,10 +106,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 +124,16 @@ 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")
+ # Owner-inclusive search + per-column sort (name/owner/created); re-wraps
+ # via pk__in to drop the DISTINCT ON in for_user() so any column sorts.
+ return apply_search_and_sort(
+ queryset,
+ model=ConnectorInstance,
+ name_field="connector_name",
+ request=self.request,
+ select_related=("created_by",),
+ prefetch_related=("memberships__user",),
+ )
def _get_connector_metadata(self, connector_id: str) -> dict[str, str] | None:
"""Gets connector metadata for the ConnectorInstance.
diff --git a/backend/permissions/models.py b/backend/permissions/models.py
index 9c63804e16..d6c3eecc09 100644
--- a/backend/permissions/models.py
+++ b/backend/permissions/models.py
@@ -43,6 +43,21 @@ def co_owners_count(self) -> int:
for m in self.memberships.all() # type: ignore[attr-defined]
)
+ def owner_email(self) -> str | None:
+ # Email for the "Owned By" label. ``created_by`` is audit-only (UN-2202)
+ # and the creator can be removed as owner, so it must not name the owner.
+ # Reads the prefetched ``memberships`` (list views set ``memberships__user``)
+ # to stay query-free; earliest live OWNER wins so the label names the same
+ # roster as ``co_owners_count()`` and is stable across page loads.
+ 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
+ return min(owners, key=lambda m: m.created_at).user.email
+
def is_owner(self, user: Any) -> bool:
if user is None:
return False
diff --git a/backend/prompt_studio/prompt_studio_core_v2/serializers.py b/backend/prompt_studio/prompt_studio_core_v2/serializers.py
index 245f2c0743..a09798b14a 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_email = serializers.SerializerMethodField()
class Meta:
model = CustomTool
@@ -65,6 +66,7 @@ class Meta:
"prompt_count",
"is_owner",
"co_owners_count",
+ "owner_email",
]
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_email(self, instance) -> str | None:
+ return instance.owner_email()
+
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..ea06e61dda 100644
--- a/backend/prompt_studio/prompt_studio_core_v2/views.py
+++ b/backend/prompt_studio/prompt_studio_core_v2/views.py
@@ -32,6 +32,7 @@
from tool_instance_v2.models import ToolInstance
from utils.file_storage.helpers.prompt_studio_file_helper import PromptStudioFileHelper
from utils.hubspot_notify import notify_hubspot_event
+from utils.list_query import apply_search_and_sort
from utils.pagination import OptionalPagination
from utils.user_context import UserContext
from utils.user_session import UserSessionUtils
@@ -161,7 +162,21 @@ def get_queryset(self) -> QuerySet | None:
"memberships__user"
)
if self.action == "list":
- # Subquery avoids conflict with distinct("tool_id") from for_user()
+ # Owner-inclusive search + per-column sort (name/owner/created);
+ # re-wraps via pk__in to drop the DISTINCT ON in for_user() so any
+ # column sorts.
+ qs = apply_search_and_sort(
+ qs,
+ model=CustomTool,
+ name_field="tool_name",
+ request=self.request,
+ select_related=("created_by",),
+ prefetch_related=("memberships__user",),
+ )
+ # apply_search_and_sort returns a fresh CustomTool.objects chain, so
+ # the prompt_count annotation goes on afterwards. Subquery keeps the
+ # count out of the outer GROUP BY, which would otherwise collide with
+ # the sort column.
prompt_count_sq = (
ToolStudioPrompt.objects.filter(tool_id=OuterRef("pk"))
.order_by()
@@ -169,19 +184,9 @@ 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
- 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.annotate(_prompt_count=Subquery(prompt_count_sq))
+ # Order by the DISTINCT ON field so pagination is deterministic for the
+ # admin/service (non-list) branch.
return qs.order_by("tool_id")
def get_object(self):
diff --git a/backend/utils/list_query.py b/backend/utils/list_query.py
new file mode 100644
index 0000000000..d86ece0673
--- /dev/null
+++ b/backend/utils/list_query.py
@@ -0,0 +1,75 @@
+"""Shared list-query helpers for resource list endpoints.
+
+Provides owner-inclusive ``?search`` and per-column sorting (name / owner /
+created) with a ``pk__in`` re-wrap so managers ending in Postgres ``DISTINCT
+ON`` can still be ordered by an arbitrary column.
+"""
+
+from typing import Any
+
+from django.db.models import Model, Q, QuerySet
+from rest_framework.request import Request
+
+# ``name`` maps to the resource-specific name field each caller passes;
+# owner/created are shared across every list endpoint.
+OWNER_SORT_FIELD = "created_by__email"
+CREATED_SORT_FIELD = "created_at"
+
+
+def apply_search_and_sort(
+ queryset: QuerySet[Any],
+ *,
+ model: type[Model],
+ name_field: str,
+ request: Request,
+ select_related: tuple[str, ...] = (),
+ prefetch_related: tuple[str, ...] = (),
+ default_sort_by: str = "name",
+) -> QuerySet[Any]:
+ """Apply owner-inclusive ``?search`` and ``?sort_by``/``?order`` to a list
+ queryset.
+
+ ``sort_by`` is ``name`` | ``owner`` | ``created`` (default ``name``);
+ ``order`` is ``asc`` | ``desc`` (default ``asc``). The queryset is re-wrapped
+ via ``pk__in`` to drop any ``DISTINCT ON`` (so ordering by a non-distinct
+ column is legal) and a ``pk`` tiebreaker is appended for stable pagination.
+ ``select_related`` / ``prefetch_related`` are re-attached to the re-wrapped
+ queryset to keep the list free of N+1 owner/co-owner lookups.
+
+ Args:
+ queryset: The already org-scoped, ``for_user``-filtered list queryset.
+ model: The concrete resource model, used to re-wrap via ``pk__in``.
+ name_field: The resource's name column (e.g. ``adapter_name``).
+ request: DRF request carrying ``search`` / ``sort_by`` / ``order``.
+ select_related: FK joins to re-attach after the re-wrap.
+ prefetch_related: Reverse/M2M prefetches to re-attach after the re-wrap.
+ default_sort_by: Sort key used when ``?sort_by`` is absent.
+
+ Returns:
+ An ordered queryset ready for pagination.
+ """
+ params = request.query_params
+
+ search = params.get("search")
+ if search:
+ queryset = queryset.filter(
+ Q(**{f"{name_field}__icontains": search})
+ | Q(**{f"{OWNER_SORT_FIELD}__icontains": search})
+ )
+
+ sort_field = {
+ "name": name_field,
+ "owner": OWNER_SORT_FIELD,
+ "created": CREATED_SORT_FIELD,
+ }.get((params.get("sort_by") or default_sort_by).lower(), name_field)
+ order_prefix = "-" if (params.get("order") or "asc").lower() == "desc" else ""
+
+ # Ordering the source by ``pk`` keeps the DISTINCT ON (always the pk) valid
+ # while stripping the model's default ordering, so the outer query is free
+ # to sort by any column.
+ rewrapped = model.objects.filter(pk__in=queryset.order_by("pk").values("pk"))
+ if select_related:
+ rewrapped = rewrapped.select_related(*select_related)
+ if prefetch_related:
+ rewrapped = rewrapped.prefetch_related(*prefetch_related)
+ return rewrapped.order_by(f"{order_prefix}{sort_field}", "pk")
diff --git a/backend/workflow_manager/workflow_v2/serializers.py b/backend/workflow_manager/workflow_v2/serializers.py
index c9715a9acb..c2f037570c 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_email"] = instance.owner_email()
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..5dcb1bc969 100644
--- a/backend/workflow_manager/workflow_v2/views.py
+++ b/backend/workflow_manager/workflow_v2/views.py
@@ -21,6 +21,7 @@
from rest_framework.versioning import URLPathVersioning
from rest_framework.views import APIView
from utils.filtering import FilterHelper
+from utils.list_query import apply_search_and_sort
from utils.organization_utils import filter_queryset_by_organization, resolve_organization
from utils.pagination import OptionalPagination
@@ -110,24 +111,18 @@ def get_queryset(self) -> QuerySet:
if filter_args
else Workflow.objects.for_user(self.request.user)
)
- # Avoid per-row queries for owner/co-owner + creator fields in list views
- 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)
- # `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")
-
- return queryset
+ # Owner-inclusive search + per-column sort (name/owner/created); re-wraps
+ # via pk__in (harmless here — for_user() uses plain .distinct()) and
+ # re-attaches the owner/co-owner joins to avoid N+1 in list views.
+ return apply_search_and_sort(
+ queryset,
+ model=Workflow,
+ name_field="workflow_name",
+ request=self.request,
+ select_related=("created_by",),
+ prefetch_related=("memberships__user",),
+ )
def get_serializer_class(self) -> serializers.Serializer:
if self.action == "execute":
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..0e07bc86bd 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,106 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
);
const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ pagination,
+ setPagination,
+ searchTerm,
+ setSearchTerm,
+ sort,
+ fetchRef,
+ requestList,
+ syncRequested,
+ handlePaginationChange,
+ handleSearch,
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({ defaultPageSize: DEFAULT_PAGE_SIZE });
+
+ 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);
+ getListOfTools(1, DEFAULT_PAGE_SIZE, "", "", "asc");
+ // 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 +238,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 +251,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 +270,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 +341,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 +351,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 +372,7 @@ function ListOfTools({ segmentOptions, segmentValue, onSegmentChange }) {
.then((res) => {
setOpenSharePermissionModal(true);
setPromptDetails(res?.data);
- setIsPermissionEdit(isEdit);
+ setIsPermissionEdit(isEditShare);
})
.catch((err) => {
setAlertDetails(handleException(err));
@@ -407,30 +433,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..2bd277721a 100644
--- a/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx
+++ b/frontend/src/components/deployments/api-deployment/ApiDeployment.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { deploymentApiTypes, displayURL } from "../../../helpers/GetStaticData";
@@ -71,26 +71,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 {
@@ -182,7 +179,7 @@ function ApiDeployment() {
});
};
- fetchListRef.current = getApiDeploymentList;
+ fetchRef.current = getApiDeploymentList;
const deleteApiDeployment = (item) => {
const id = item?.id || selectedRow.id;
diff --git a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
index 32fdd4ae5b..f2688b6ea5 100644
--- a/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
+++ b/frontend/src/components/pipelines-or-deployments/pipelines/Pipelines.jsx
@@ -1,5 +1,5 @@
import PropTypes from "prop-types";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import {
@@ -75,26 +75,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 {
@@ -184,7 +181,7 @@ function Pipelines({ type }) {
});
};
- fetchListRef.current = getPipelineList;
+ fetchRef.current = getPipelineList;
const handleSync = (params) => {
const body = { ...params, pipeline_type: type.toUpperCase() };
diff --git a/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx b/frontend/src/components/tool-settings/tool-settings/ToolSettings.jsx
index 04f41d7694..92ebba1f1d 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,135 @@ function ToolSettings({ type }) {
);
const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ pagination,
+ setPagination,
+ searchTerm,
+ sort,
+ fetchRef,
+ requestList,
+ resetList,
+ syncRequested,
+ handlePaginationChange,
+ handleSearch,
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({ defaultPageSize: DEFAULT_PAGE_SIZE });
+
+ 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 +322,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 +332,7 @@ function ToolSettings({ type }) {
});
return;
}
- handleCoOwnerAction(adapter.id);
+ coOwner.handleCoOwner(adapter.id);
};
const handleOpenAddSourceModal = () => {
@@ -297,8 +353,7 @@ function ToolSettings({ type }) {
title={titles[type]}
enableSearch
searchKey={type}
- setSearchList={setDisplayList}
- onSearch={onSearch}
+ onSearch={(value) => 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 +430,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..47807e90f0
--- /dev/null
+++ b/frontend/src/components/widgets/resource-table/ResourceTable.css
@@ -0,0 +1,169 @@
+/* Styles for the sortable resource list table (Name / Owned By / Created / 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;
+}
+
+/* Owned By column: avatar + name/email, clickable to manage co-owners */
+.resource-table-owner {
+ min-width: 0;
+}
+
+.resource-table-owner-avatar {
+ font-size: 12px;
+ 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 .disabled-icon {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Destructive action is red, per the design */
+.resource-table .delete-icon:not(.disabled-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..715c262cb5
--- /dev/null
+++ b/frontend/src/components/widgets/resource-table/ResourceTable.jsx
@@ -0,0 +1,396 @@
+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 "./ResourceTable.css";
+
+// Stable, distinct avatar swatch per owner (seeded on email/name) like the design.
+const AVATAR_COLORS = [
+ "#f56a00",
+ "#7265e6",
+ "#00a2ae",
+ "#d48806",
+ "#1677ff",
+ "#eb2f96",
+ "#52c41a",
+ "#722ed1",
+];
+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: },
+ ],
+};
+
+const formatDate = (value) => {
+ if (!value) {
+ return "-";
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "-";
+ }
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+};
+
+/**
+ * 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, onSortChange }) {
+ const active = 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,
+ onSortChange: PropTypes.func,
+};
+
+/**
+ * Sortable resource list table (Name / Owned By / Created Date / Actions).
+ * Sorting, search and pagination are server-driven — the parent owns the fetch.
+ * @return {JSX.Element} Rendered table
+ */
+function ResourceTable({
+ dataSource,
+ loading,
+ pagination,
+ sort,
+ onPaginationChange,
+ onSortChange,
+ titleProp,
+ descriptionProp,
+ iconProp,
+ idProp,
+ dateProp = "created_at",
+ ownerEmailProp = "created_by_email",
+ handleEdit,
+ handleShare,
+ handleDelete,
+ handleCoOwner,
+ 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]}
+
+ )}
+
+
+ );
+ };
+
+ const renderOwner = (item) => {
+ const email = item?.[ownerEmailProp];
+ // "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 = (isMe ? email || "Me" : name).slice(0, 2).toUpperCase();
+
+ 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"
+ >
+
+ !deprecated && handleEdit?.(event, item)}
+ />
+
+ {handleShare && (
+
+ !deprecated && handleShare(event, item, true)}
+ />
+
+ )}
+ }
+ onConfirm={(event) => handleDelete?.(event, item)}
+ >
+
+
+
+ );
+ };
+
+ const columns = [
+ {
+ title: (
+
+ ),
+ key: "name",
+ width: "40%",
+ render: (_, item) => renderName(item),
+ },
+ showOwner && {
+ title: (
+
+ ),
+ key: "owner",
+ width: "26%",
+ render: (_, item) => renderOwner(item),
+ },
+ {
+ title: (
+
+ ),
+ key: "created",
+ width: "19%",
+ render: (_, item) => formatDate(item?.[dateProp]),
+ },
+ {
+ title: Actions,
+ key: "actions",
+ width: "15%",
+ 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 (
+ ({
+ onClick: isClickable ? () => 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,
+ onPaginationChange: PropTypes.func,
+ onSortChange: PropTypes.func,
+ titleProp: PropTypes.string.isRequired,
+ descriptionProp: PropTypes.string,
+ iconProp: PropTypes.string,
+ idProp: PropTypes.string.isRequired,
+ dateProp: PropTypes.string,
+ ownerEmailProp: PropTypes.string,
+ handleEdit: PropTypes.func,
+ handleShare: PropTypes.func,
+ handleDelete: PropTypes.func,
+ handleCoOwner: 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.jsx b/frontend/src/components/workflows/workflow/Workflows.jsx
index 7d5567c36f..7047977a90 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,16 @@ function Workflows() {
pagination,
setPagination,
searchTerm,
+ sort,
+ fetchRef,
+ requestList,
+ syncRequested,
handlePaginationChange,
handleSearch,
- } = usePaginatedList({
- fetchData: (...args) => fetchListRef.current?.(...args),
- defaultPageSize: DEFAULT_PAGE_SIZE,
- });
-
- // Refresh the current page (preserves page + active search) after mutations
- const handleListRefresh = useCallback(
- () =>
- fetchListRef.current?.(
- pagination.current,
- pagination.pageSize,
- searchTerm,
- ),
- [pagination.current, pagination.pageSize, searchTerm],
- );
- const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({ defaultPageSize: DEFAULT_PAGE_SIZE });
+ const coOwner = useCoOwnerManagement({
service: projectApiService,
setAlertDetails,
onListRefresh: handleListRefresh,
@@ -115,46 +106,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 +176,7 @@ function Workflows() {
);
})
.finally(() => {
- setLoading(false);
+ setEditLoading(false);
});
}
@@ -360,7 +358,7 @@ function Workflows() {
const handleCoOwner = (event, workflow) => {
event.stopPropagation();
- handleCoOwnerAction(workflow.id);
+ coOwner.handleCoOwner(workflow.id);
};
const handleNewWorkflowBtnClick = () => {
@@ -406,8 +404,15 @@ function Workflows() {
/>
- {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/hooks/usePaginatedList.js b/frontend/src/hooks/usePaginatedList.js
index ce45ffa3f4..fc75114347 100644
--- a/frontend/src/hooks/usePaginatedList.js
+++ b/frontend/src/hooks/usePaginatedList.js
@@ -1,44 +1,207 @@
-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) {
+ params.sort_by = sortBy;
+ params.order = order;
+ }
+ 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;
+ }
+ 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) {
+ 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,
+ });
- 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 on-screen view (setPagination only runs when a
+ // response applies); search/sortBy/order are the LAST-REQUESTED values, since
+ // handleSearch/handleSortChange commit them before firing and don't roll back
+ // on failure. syncRequested copies this into requestedRef so a later refresh
+ // targets the displayed page rather than a mid-flight one.
+ 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 (not read from state,
+ // which lags — pagination only updates when a response applies). handleList-
+ // Refresh replays these, so a refresh captured during an in-flight navigation
+ // still targets the view the user asked for rather than snapping back.
+ 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 with what
+ // ends up on screen. Pages route their stepback (refetchPrevPage) through it.
+ const requestList = (page, pageSize, search, sortBy, order) => {
+ requestedRef.current = { page, pageSize, search, sortBy, order };
+ // Return the fetch promise so a stepback (refetchPrevPage) propagates up
+ // through applyPagedResponse, as its JSDoc documents.
+ 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) => {
+ const nextSort = { sortBy: sortBy || "", order: order || "asc" };
+ setSort(nextSort);
+ 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 });
+ 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,
+ 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..3528c19866 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,86 @@ function ConnectorsPage() {
);
const {
- coOwnerOpen,
- setCoOwnerOpen,
- coOwnerData,
- coOwnerLoading,
- coOwnerAllUsers,
- coOwnerResourceId,
- handleCoOwner: handleCoOwnerAction,
- onAddCoOwner,
- onRemoveCoOwner,
- } = useCoOwnerManagement({
+ pagination,
+ setPagination,
+ searchTerm,
+ sort,
+ fetchRef,
+ requestList,
+ syncRequested,
+ handlePaginationChange,
+ handleSearch,
+ handleSortChange,
+ handleListRefresh,
+ } = usePaginatedList({ defaultPageSize: DEFAULT_PAGE_SIZE });
+
+ 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();
+ getConnectors(1, DEFAULT_PAGE_SIZE, "", "", "asc");
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 +194,7 @@ function ConnectorsPage() {
type: "success",
content: "Connector deleted successfully",
});
- fetchConnectors();
+ handleListRefresh();
} catch (error) {
setAlertDetails(handleException(error, "Failed to delete connector"));
}
@@ -204,14 +264,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 +299,52 @@ function ConnectorsPage() {
handleSearch(value)}
customButtons={newConnectorButton}
/>
-
+ {loadError && (
+
+ )}
+ {!loadError && displayList === undefined && }
+ {!loadError && displayList?.length === 0 && !searchTerm && (
+
+ )}
+ {!loadError && displayList?.length === 0 && searchTerm && (
+
+ )}
+ {!loadError && displayList?.length > 0 && (
+
+ )}
-
+
);
}