diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index d3ad49b5fda..ec2f277c29a 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -42,6 +42,7 @@ UserRecentVisit, ) from plane.utils.error_codes import ERROR_CODES +from plane.utils.page_filters import page_filters # Local imports from ..base import BaseAPIView, BaseViewSet @@ -96,9 +97,7 @@ def get_queryset(self): .select_related("workspace") .select_related("owned_by") .annotate(is_favorite=Exists(subquery)) - .order_by(self.request.GET.get("order_by", "-created_at")) .prefetch_related("labels") - .order_by("-is_favorite", "-created_at") .annotate( project=Exists( ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) @@ -285,8 +284,9 @@ def access(self, request, slug, project_id, page_id): return Response(status=status.HTTP_204_NO_CONTENT) def list(self, request, slug, project_id): - queryset = self.get_queryset() project = Project.objects.get(pk=project_id) + queryset = self.get_queryset() + if ( ProjectMember.objects.filter( workspace__slug=slug, @@ -298,8 +298,34 @@ def list(self, request, slug, project_id): and not project.guest_view_all_features ): queryset = queryset.filter(owned_by=request.user) - pages = PageSerializer(queryset, many=True).data - return Response(pages, status=status.HTTP_200_OK) + + # Filter by page type (public, private, archived) + page_type = request.GET.get("type", "public") + if page_type == "private": + queryset = queryset.filter(access=1, archived_at__isnull=True) + elif page_type == "archived": + queryset = queryset.filter(archived_at__isnull=False) + elif page_type == "public": + queryset = queryset.filter(access=0, archived_at__isnull=True) + + # Apply additional filters from query params + filters = page_filters(request.query_params, "GET") + queryset = queryset.filter(**filters) + + order_by_param = request.GET.get("order_by", "-created_at") + ALLOWED_ORDER_FIELDS = {"created_at", "-created_at", "updated_at", "-updated_at", "name", "-name"} + + if order_by_param not in ALLOWED_ORDER_FIELDS: + order_by_param = "-created_at" + queryset = queryset.order_by(order_by_param) + + return self.paginate( + request=request, + queryset=queryset, + on_results=lambda pages: PageSerializer(pages, many=True).data, + default_per_page=20, + max_per_page=100, + ) def archive(self, request, slug, project_id, page_id): page = Page.objects.get( diff --git a/apps/api/plane/utils/page_filters.py b/apps/api/plane/utils/page_filters.py new file mode 100644 index 00000000000..2642389ca36 --- /dev/null +++ b/apps/api/plane/utils/page_filters.py @@ -0,0 +1,156 @@ +# Python imports +import re +import uuid +from datetime import timedelta + +# Django imports +from django.utils import timezone + + +# The date from pattern +pattern = re.compile(r"\d+_(weeks|months)$") + + +# check the valid uuids +def filter_valid_uuids(uuid_list): + valid_uuids = [] + for uuid_str in uuid_list: + try: + uuid_obj = uuid.UUID(uuid_str) + valid_uuids.append(uuid_obj) + except ValueError: + # ignore the invalid uuids + pass + return valid_uuids + + +# Get the 2_weeks, 3_months +def string_date_filter(page_filter, duration, subsequent, term, date_filter, offset): + now = timezone.now().date() + if term == "months": + if subsequent == "after": + if offset == "fromnow": + page_filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30) + else: + page_filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30) + else: + if offset == "fromnow": + page_filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30) + else: + page_filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30) + if term == "weeks": + if subsequent == "after": + if offset == "fromnow": + page_filter[f"{date_filter}__gte"] = now + timedelta(weeks=duration) + else: + page_filter[f"{date_filter}__gte"] = now - timedelta(weeks=duration) + else: + if offset == "fromnow": + page_filter[f"{date_filter}__lte"] = now + timedelta(weeks=duration) + else: + page_filter[f"{date_filter}__lte"] = now - timedelta(weeks=duration) + + +def date_filter(page_filter, date_term, queries): + """ + Handle all date filters + """ + for query in queries: + date_query = query.split(";") + if date_query: + if len(date_query) >= 2: + match = pattern.match(date_query[0]) + if match: + if len(date_query) == 3: + digit, term = date_query[0].split("_") + string_date_filter( + page_filter=page_filter, + duration=int(digit), + subsequent=date_query[1], + term=term, + date_filter=date_term, + offset=date_query[2], + ) + else: + if "after" in date_query: + page_filter[f"{date_term}__gte"] = date_query[0] + else: + page_filter[f"{date_term}__lte"] = date_query[0] + else: + page_filter[f"{date_term}__contains"] = date_query[0] + return page_filter + + +def filter_created_by(params, page_filter, method, prefix=""): + if method == "GET": + created_bys = [item for item in params.get("created_by").split(",") if item != "null"] + if "None" in created_bys: + page_filter[f"{prefix}owned_by__isnull"] = True + created_bys = filter_valid_uuids(created_bys) + if len(created_bys) and "" not in created_bys: + page_filter[f"{prefix}owned_by__in"] = created_bys + else: + if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != "null": + page_filter[f"{prefix}owned_by__in"] = params.get("created_by") + return page_filter + + +def filter_created_at(params, page_filter, method, prefix=""): + if method == "GET": + created_ats = params.get("created_at").split(",") + if len(created_ats) and "" not in created_ats: + date_filter( + page_filter=page_filter, + date_term=f"{prefix}created_at__date", + queries=created_ats, + ) + else: + if params.get("created_at", None) and len(params.get("created_at")): + date_filter( + page_filter=page_filter, + date_term=f"{prefix}created_at__date", + queries=params.get("created_at", []), + ) + return page_filter + + +def filter_favorites(params, page_filter, method, prefix=""): + if method == "GET": + favorites = params.get("favorites", "false").lower() + if favorites == "true": + page_filter[f"{prefix}is_favorite"] = True + else: + if params.get("favorites", False): + page_filter[f"{prefix}is_favorite"] = True + + return page_filter + + +def filter_search(params, page_filter, method, prefix=""): + if method == "GET": + search = params.get("search", None) + if search: + page_filter[f"{prefix}name__icontains"] = search + else: + if params.get("search", None): + page_filter[f"{prefix}name__icontains"] = params.get("search") + + return page_filter + + +def page_filters(query_params, method, prefix=""): + page_filter = {} + + PAGE_FILTER_MAP = { + "created_by": filter_created_by, + "created_at": filter_created_at, + "favorites": filter_favorites, + "search": filter_search, + } + + for key, value in PAGE_FILTER_MAP.items(): + if key in query_params: + func = value + func(query_params, page_filter, method, prefix) + + return page_filter diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx index 59f609dbfbb..6ae73282ed4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx @@ -1,5 +1,6 @@ // component import { Outlet } from "react-router"; +import { useSearchParams } from "next/navigation"; import useSWR from "swr"; import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; @@ -8,12 +9,22 @@ import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; // local components import type { Route } from "./+types/layout"; import { PageDetailsHeader } from "./header"; +import type { TPageNavigationTabs } from "@plane/types"; + +const getPageType = (pageType?: string | null): TPageNavigationTabs => { + if (pageType === "private") return "private"; + if (pageType === "archived") return "archived"; + return "public"; +}; export default function ProjectPageDetailsLayout({ params }: Route.ComponentProps) { const { workspaceSlug, projectId } = params; const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT); + const searchParams = useSearchParams(); + const type = searchParams.get("type"); + const pageType = getPageType(type); // fetching pages list - useSWR(`PROJECT_PAGES_${projectId}`, () => fetchPagesList(workspaceSlug, projectId)); + useSWR(`PROJECT_PAGES_${projectId}`, () => fetchPagesList(workspaceSlug, projectId, pageType)); return ( <> } /> diff --git a/apps/web/core/components/pages/list/loader.tsx b/apps/web/core/components/pages/list/loader.tsx new file mode 100644 index 00000000000..6c504d9b3ed --- /dev/null +++ b/apps/web/core/components/pages/list/loader.tsx @@ -0,0 +1,15 @@ +import { Loader } from "@plane/ui"; + +export function PageListLoader() { + return ( + + +
+ + + + +
+
+ ); +} diff --git a/apps/web/core/components/pages/list/root.tsx b/apps/web/core/components/pages/list/root.tsx index 0d477745fef..8a36546fda9 100644 --- a/apps/web/core/components/pages/list/root.tsx +++ b/apps/web/core/components/pages/list/root.tsx @@ -1,13 +1,18 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // types import type { TPageNavigationTabs } from "@plane/types"; // components import { ListLayout } from "@/components/core/list"; +// hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; // plane web hooks import type { EPageStoreType } from "@/plane-web/hooks/store"; import { usePageStore } from "@/plane-web/hooks/store"; // local imports import { PageListBlock } from "./block"; +import { PageListLoader } from "./loader"; type TPagesListRoot = { pageType: TPageNavigationTabs; @@ -16,17 +21,58 @@ type TPagesListRoot = { export const PagesListRoot = observer(function PagesListRoot(props: TPagesListRoot) { const { pageType, storeType } = props; + // params + const { workspaceSlug, projectId } = useParams(); // store hooks - const { getCurrentProjectFilteredPageIdsByTab } = usePageStore(storeType); + const { getCurrentProjectFilteredPageIdsByTab, fetchPagesList, getPaginationInfo, getPaginationLoader } = + usePageStore(storeType); + + // pagination hooks + const paginationInfo = getPaginationInfo(pageType); + const paginationLoader = getPaginationLoader(pageType); + const hasNextPage = paginationInfo.hasNextPage; + const isFetchingNextPage = paginationLoader === "pagination"; + + // state for intersection observer element + const [intersectionElement, setIntersectionElement] = useState(null); + // ref for container - we'll wrap ListLayout + const containerRef = useRef(null); + // derived values const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType); + // Function to fetch next page + const fetchNextPage = useCallback(() => { + if (!workspaceSlug || !projectId || !hasNextPage || isFetchingNextPage) { + return; + } + // Use fetchPagesList with cursor from pagination info + void fetchPagesList( + workspaceSlug.toString(), + projectId.toString(), + pageType, + paginationInfo.nextCursor ?? undefined + ); + }, [workspaceSlug, projectId, hasNextPage, isFetchingNextPage, fetchPagesList, pageType, paginationInfo.nextCursor]); + + // Set up intersection observer to trigger loading more pages + useIntersectionObserver( + containerRef, + isFetchingNextPage ? null : intersectionElement, + fetchNextPage, + `100% 0% 100% 0%` + ); + if (!filteredPageIds) return <>; + return ( - - {filteredPageIds.map((pageId) => ( - - ))} - +
+ + {filteredPageIds.map((pageId) => ( + + ))} + {hasNextPage &&
{isFetchingNextPage && }
} +
+
); }); diff --git a/apps/web/core/components/pages/pages-list-main-content.tsx b/apps/web/core/components/pages/pages-list-main-content.tsx index 58e239140b9..8396e53c78d 100644 --- a/apps/web/core/components/pages/pages-list-main-content.tsx +++ b/apps/web/core/components/pages/pages-list-main-content.tsx @@ -156,6 +156,11 @@ export const PagesListMainContent = observer(function PagesListMainContent(props /> ); } + + if (loader === "mutation-loader" && (!filteredPageIds || filteredPageIds.length === 0)) { + return ; + } + // if no pages match the filter criteria if (filteredPageIds?.length === 0) return ( diff --git a/apps/web/core/components/pages/pages-list-view.tsx b/apps/web/core/components/pages/pages-list-view.tsx index 15f114f46ec..86e3e25ddac 100644 --- a/apps/web/core/components/pages/pages-list-view.tsx +++ b/apps/web/core/components/pages/pages-list-view.tsx @@ -19,11 +19,20 @@ type TPageView = { export const PagesListView = observer(function PagesListView(props: TPageView) { const { children, pageType, projectId, storeType, workspaceSlug } = props; // store hooks - const { isAnyPageAvailable, fetchPagesList } = usePageStore(storeType); - // fetching pages list + const { isAnyPageAvailable, fetchPagesList, filters } = usePageStore(storeType); + + // fetching pages list - include filters and sorting in SWR key to refetch when they change useSWR( - workspaceSlug && projectId && pageType ? `PROJECT_PAGES_${projectId}` : null, - workspaceSlug && projectId && pageType ? () => fetchPagesList(workspaceSlug, projectId, pageType) : null + workspaceSlug && projectId && pageType + ? `PROJECT_PAGES_${projectId}_${pageType}_${filters.searchQuery || ""}_${filters.sortKey || ""}_${ + filters.sortBy || "" + }_${JSON.stringify(filters.filters || {})}` + : null, + workspaceSlug && projectId && pageType ? () => fetchPagesList(workspaceSlug, projectId, pageType) : null, + { + revalidateOnFocus: true, + revalidateIfStale: true, + } ); // pages loader diff --git a/apps/web/core/services/page/project-page.service.ts b/apps/web/core/services/page/project-page.service.ts index 16a67038428..7443add803c 100644 --- a/apps/web/core/services/page/project-page.service.ts +++ b/apps/web/core/services/page/project-page.service.ts @@ -1,11 +1,16 @@ // types import { API_BASE_URL } from "@plane/constants"; -import type { TDocumentPayload, TPage } from "@plane/types"; +import type { TDocumentPayload, TPage, TPaginationInfo } from "@plane/types"; // helpers // services import { APIService } from "@/services/api.service"; import { FileUploadService } from "@/services/file-upload.service"; +// Paginated response type for pages +export type TPagePaginatedResponse = TPaginationInfo & { + results: TPage[]; +}; + export class ProjectPageService extends APIService { private fileUploadService: FileUploadService; @@ -15,8 +20,14 @@ export class ProjectPageService extends APIService { this.fileUploadService = new FileUploadService(); } - async fetchAll(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`) + async fetchAll( + workspaceSlug: string, + projectId: string, + queries?: Record + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, { + params: queries, + }) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/apps/web/core/store/pages/project-page.store.ts b/apps/web/core/store/pages/project-page.store.ts index 040fea59a7d..095ce3be027 100644 --- a/apps/web/core/store/pages/project-page.store.ts +++ b/apps/web/core/store/pages/project-page.store.ts @@ -18,9 +18,27 @@ import type { TProjectPage } from "./project-page"; import { ProjectPage } from "./project-page"; type TLoader = "init-loader" | "mutation-loader" | undefined; +type TPaginationLoader = "pagination" | undefined; type TError = { title: string; description: string }; +// Pagination info for each page type +export type TPagePaginationInfo = { + nextCursor: string | null; + hasNextPage: boolean; + totalResults: number; +}; + +// Default pagination info +const DEFAULT_PAGINATION_INFO: TPagePaginationInfo = { + nextCursor: null, + hasNextPage: false, // Don't assume there's a next page until API tells us + totalResults: 0, +}; + +// Per page count for pagination +const PAGES_PER_PAGE = 20; + export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [ EUserPermissions.ADMIN, EUserPermissions.MEMBER, @@ -34,9 +52,17 @@ export interface IProjectPageStore { data: Record; // pageId => Page error: TError | undefined; filters: TPageFilters; + // filtered page type arrays + filteredPublicPageIds: string[]; + filteredPrivatePageIds: string[]; + filteredArchivedPageIds: string[]; + // pagination info per page type + paginationInfo: Record; + paginationLoader: Record; // computed isAnyPageAvailable: boolean; canCurrentUserCreatePage: boolean; + hasActiveFilters: boolean; // helper actions getCurrentProjectPageIdsByTab: (pageType: TPageNavigationTabs) => string[] | undefined; getCurrentProjectPageIds: (projectId: string) => string[]; @@ -44,11 +70,14 @@ export interface IProjectPageStore { getPageById: (pageId: string) => TProjectPage | undefined; updateFilters: (filterKey: T, filterValue: TPageFilters[T]) => void; clearAllFilters: () => void; + getPaginationInfo: (pageType: TPageNavigationTabs) => TPagePaginationInfo; + getPaginationLoader: (pageType: TPageNavigationTabs) => TPaginationLoader; // actions fetchPagesList: ( workspaceSlug: string, projectId: string, - pageType?: TPageNavigationTabs + pageType: TPageNavigationTabs, + cursor?: string ) => Promise; fetchPageDetails: ( workspaceSlug: string, @@ -71,6 +100,21 @@ export class ProjectPageStore implements IProjectPageStore { sortKey: "updated_at", sortBy: "desc", }; + // filtered page type arrays + filteredPublicPageIds: string[] = []; + filteredPrivatePageIds: string[] = []; + filteredArchivedPageIds: string[] = []; + // pagination info per page type + paginationInfo: Record = { + public: { ...DEFAULT_PAGINATION_INFO }, + private: { ...DEFAULT_PAGINATION_INFO }, + archived: { ...DEFAULT_PAGINATION_INFO }, + }; + paginationLoader: Record = { + public: undefined, + private: undefined, + archived: undefined, + }; // service service: ProjectPageService; rootStore: CoreRootStore; @@ -82,9 +126,16 @@ export class ProjectPageStore implements IProjectPageStore { data: observable, error: observable, filters: observable, + filteredPublicPageIds: observable, + filteredPrivatePageIds: observable, + filteredArchivedPageIds: observable, + // pagination info + paginationInfo: observable, + paginationLoader: observable, // computed isAnyPageAvailable: computed, canCurrentUserCreatePage: computed, + hasActiveFilters: computed, // helper actions updateFilters: action, clearAllFilters: action, @@ -128,6 +179,33 @@ export class ProjectPageStore implements IProjectPageStore { return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole); } + /** + * @description check if there are any active filters applied + */ + get hasActiveFilters() { + // Check if search query is active + if (this.filters.searchQuery && this.filters.searchQuery.trim().length > 0) { + return true; + } + + // Check if any filters are applied + if (this.filters.filters) { + const filterValues = Object.values(this.filters.filters); + // Check if any filter has a non-empty value + return filterValues.some((value) => { + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === "boolean") { + return value === true; + } + return value !== null && value !== undefined; + }); + } + + return false; + } + /** * @description get the current project page ids based on the pageType * @param {TPageNavigationTabs} pageType @@ -162,19 +240,17 @@ export class ProjectPageStore implements IProjectPageStore { const { projectId } = this.store.router; if (!projectId) return undefined; - // helps to filter pages based on the pageType - const pagesByType = filterPagesByPageType(pageType, Object.values(this?.data || {})); - let filteredPages = pagesByType.filter( - (p) => - p.project_ids?.includes(projectId) && - getPageName(p.name).toLowerCase().includes(this.filters.searchQuery.toLowerCase()) && - shouldFilterPage(p, this.filters.filters) - ); - filteredPages = orderPages(filteredPages, this.filters.sortKey, this.filters.sortBy); - - const pages = (filteredPages.map((page) => page.id) as string[]) || undefined; - - return pages ?? undefined; + // Return filtered page IDs from the filtered arrays + switch (pageType) { + case "public": + return this.filteredPublicPageIds; + case "private": + return this.filteredPrivatePageIds; + case "archived": + return this.filteredArchivedPageIds; + default: + return []; + } }); /** @@ -198,26 +274,148 @@ export class ProjectPageStore implements IProjectPageStore { }); /** - * @description fetch all the pages + * @description get pagination info for a page type + * @param {TPageNavigationTabs} pageType */ - fetchPagesList = async (workspaceSlug: string, projectId: string, pageType?: TPageNavigationTabs) => { + getPaginationInfo = computedFn((pageType: TPageNavigationTabs): TPagePaginationInfo => { + return this.paginationInfo[pageType] ?? DEFAULT_PAGINATION_INFO; + }); + + /** + * @description get pagination loader for a page type + * @param {TPageNavigationTabs} pageType + */ + getPaginationLoader = computedFn((pageType: TPageNavigationTabs): TPaginationLoader => { + return this.paginationLoader[pageType]; + }); + + /** + * @description fetch all the pages with pagination support + * @param {string} workspaceSlug + * @param {string} projectId + * @param {TPageNavigationTabs} pageType - The type of pages to fetch + * @param {string} [cursor] - Optional cursor for pagination. If not provided, fetches first page + */ + fetchPagesList = async (workspaceSlug: string, projectId: string, pageType: TPageNavigationTabs, cursor?: string) => { try { if (!workspaceSlug || !projectId) return undefined; - const currentPageIds = pageType ? this.getCurrentProjectPageIdsByTab(pageType) : undefined; + const pageNavigationTab = pageType as TPageNavigationTabs; + const isFirstPage = !cursor; + + // For next page fetches, validate pagination state + if (!isFirstPage) { + const paginationInfo = this.paginationInfo[pageNavigationTab]; + // Don't fetch if there's no next page + if (!paginationInfo?.hasNextPage || !paginationInfo?.nextCursor) { + return undefined; + } + + // Don't fetch if already loading + const loader = this.paginationLoader[pageNavigationTab]; + if (loader === "pagination") { + return undefined; + } + } + + // Build query parameters + const queries: Record = { + per_page: PAGES_PER_PAGE, + type: pageType, // Send page type to backend for filtering + }; + + // Add search query if provided + if (this.filters.searchQuery) { + queries.search = this.filters.searchQuery; + } + + // Add sorting parameters + // Convert sortKey and sortBy to Django order_by format + if (this.filters.sortKey && this.filters.sortBy) { + const orderByField = this.filters.sortBy === "desc" ? `-${this.filters.sortKey}` : this.filters.sortKey; + queries.order_by = orderByField; + } + + // Add cursor if provided + if (!isFirstPage) { + const nextCursor = this.paginationInfo[pageNavigationTab].nextCursor; + if (nextCursor) { + queries.cursor = nextCursor; + } + } + + // Add filter parameters from store filters + const storeFilters = this.filters.filters || {}; + if (storeFilters.favorites) { + queries.favorites = "true"; + } + if (storeFilters.created_by && Array.isArray(storeFilters.created_by) && storeFilters.created_by.length > 0) { + queries.created_by = storeFilters.created_by.join(","); + } + if (storeFilters.created_at && Array.isArray(storeFilters.created_at) && storeFilters.created_at.length > 0) { + queries.created_at = storeFilters.created_at.join(","); + } + + // Set appropriate loader and clear store on first page runInAction(() => { - this.loader = currentPageIds && currentPageIds.length > 0 ? `mutation-loader` : `init-loader`; + if (isFirstPage) { + const currentPageIds = this.getCurrentProjectPageIdsByTab(pageType); + this.loader = currentPageIds && currentPageIds.length > 0 ? `mutation-loader` : `init-loader`; + + // Reset pagination info when fetching first page + this.paginationInfo[pageNavigationTab] = { ...DEFAULT_PAGINATION_INFO }; + + // Clear filtered arrays immediately + switch (pageNavigationTab) { + case "public": + this.filteredPublicPageIds = []; + break; + case "private": + this.filteredPrivatePageIds = []; + break; + case "archived": + this.filteredArchivedPageIds = []; + break; + } + } else { + // Set pagination loader + this.paginationLoader[pageNavigationTab] = "pagination"; + } this.error = undefined; }); - const pages = await this.service.fetchAll(workspaceSlug, projectId); + const response = await this.service.fetchAll(workspaceSlug, projectId, queries); + + // Parse response + let pages: TPage[] = []; + let paginationData: TPagePaginationInfo = { + nextCursor: null, + hasNextPage: false, + totalResults: 0, + }; + + if (Array.isArray(response)) { + pages = response; + paginationData.totalResults = response.length; + } else { + pages = response.results || []; + paginationData = { + nextCursor: response.next_cursor ?? null, + hasNextPage: response.next_page_results ?? false, + totalResults: response.total_results ?? 0, + }; + } + runInAction(() => { + // Get page IDs from response + const responsePageIds = pages.map((p) => p.id).filter((id): id is string => id !== undefined); + + // Add/update pages in store for (const page of pages) { if (page?.id) { const existingPage = this.getPageById(page.id); if (existingPage) { // If page already exists, update all fields except name - const { name, ...otherFields } = page; existingPage.mutateProperties(otherFields, false); } else { @@ -226,16 +424,54 @@ export class ProjectPageStore implements IProjectPageStore { } } } - this.loader = undefined; + + // Populate filtered arrays + switch (pageNavigationTab) { + case "public": + this.filteredPublicPageIds = isFirstPage + ? responsePageIds + : Array.from(new Set([...this.filteredPublicPageIds, ...responsePageIds])); + break; + case "private": + this.filteredPrivatePageIds = isFirstPage + ? responsePageIds + : Array.from(new Set([...this.filteredPrivatePageIds, ...responsePageIds])); + break; + case "archived": + this.filteredArchivedPageIds = isFirstPage + ? responsePageIds + : Array.from(new Set([...this.filteredArchivedPageIds, ...responsePageIds])); + break; + } + + // Update pagination info + this.paginationInfo[pageNavigationTab] = paginationData; + + // Clear appropriate loader + if (isFirstPage) { + this.loader = undefined; + } else { + this.paginationLoader[pageNavigationTab] = undefined; + } }); return pages; } catch (error) { runInAction(() => { - this.loader = undefined; + const pageNavigationTab = pageType as TPageNavigationTabs; + const isFirstPage = !cursor; + + if (isFirstPage) { + this.loader = undefined; + } else { + this.paginationLoader[pageNavigationTab] = undefined; + } + this.error = { title: "Failed", - description: "Failed to fetch the pages, Please try again later.", + description: isFirstPage + ? "Failed to fetch the pages, Please try again later." + : "Failed to fetch more pages, Please try again later.", }; }); throw error;