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 && ( + + )}
- + ); }