diff --git a/testplanit/.prettierignore b/testplanit/.prettierignore index c0c28f40..8a5b0bb1 100644 --- a/testplanit/.prettierignore +++ b/testplanit/.prettierignore @@ -15,3 +15,6 @@ dist/ # Auto-generated by semantic-release CHANGELOG.md + +# Local GSD planning artifacts (never committed) +.planning/ diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/Cases.test.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/Cases.test.tsx index 646804c7..38bfd451 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/Cases.test.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/Cases.test.tsx @@ -118,6 +118,17 @@ vi.mock("~/hooks/useRepositoryCasesWithFilteredFields", () => ({ })), })); +vi.mock("~/hooks/useRepositoryCasesByDescendants", () => ({ + useFindManyRepositoryCasesByDescendants: vi.fn(() => ({ + data: [], + isLoading: false, + isFetching: false, + error: null, + totalCount: 0, + refetch: vi.fn(), + })), +})); + vi.mock("~/hooks/useProjectPermissions", () => ({ useProjectPermissions: vi.fn(() => ({ permissions: { canAddEdit: true, canDelete: true }, @@ -275,6 +286,7 @@ vi.mock("./columns", () => ({ import { render, screen, waitFor } from "@testing-library/react"; import React from "react"; import * as NextAuth from "next-auth/react"; +import { useFindManyRepositoryCasesByDescendants } from "~/hooks/useRepositoryCasesByDescendants"; import { useFindManyRepositoryCasesFiltered } from "~/hooks/useRepositoryCasesWithFilteredFields"; import { useProjectPermissions } from "~/hooks/useProjectPermissions"; import { usePagination } from "~/lib/contexts/PaginationContext"; @@ -334,6 +346,15 @@ function setupMocks({ refetch: vi.fn(), }); + (useFindManyRepositoryCasesByDescendants as any).mockReturnValue({ + data, + isLoading, + isFetching: false, + error: null, + totalCount: data.length, + refetch: vi.fn(), + }); + (useProjectPermissions as any).mockReturnValue({ permissions: { canAddEdit, canDelete: true }, isLoading: false, diff --git a/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx b/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx index d151a2a1..24343794 100644 --- a/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx +++ b/testplanit/app/[locale]/projects/repository/[projectId]/Cases.tsx @@ -43,6 +43,7 @@ import { toast } from "sonner"; import { fetchAllCasesForExport as fetchAllCasesAction } from "~/app/actions/exportActions"; import { TFunction, useExportData } from "~/hooks/useExportData"; import { useProjectPermissions } from "~/hooks/useProjectPermissions"; +import { useFindManyRepositoryCasesByDescendants } from "~/hooks/useRepositoryCasesByDescendants"; import { PostFetchFilter, useFindManyRepositoryCasesFiltered, @@ -73,6 +74,236 @@ import { QuickScriptModal } from "./QuickScriptModal"; type PageSizeOption = number | "All"; +// Shared select shape for repository case list queries. Used by both the +// ZenStack useFindManyRepositoryCases hook (default GET path) and the +// by-folder-descendants POST endpoint (used when "Show all descendants" is +// active on a deeply nested folder) so both return identical row shapes. +const REPOSITORY_CASE_LIST_SELECT = { + id: true, + projectId: true, + project: true, + creator: true, + folder: true, + repositoryId: true, + folderId: true, + templateId: true, + name: true, + stateId: true, + estimate: true, + forecastManual: true, + forecastAutomated: true, + order: true, + createdAt: true, + creatorId: true, + automated: true, + isArchived: true, + isDeleted: true, + currentVersion: true, + source: true, + state: { + select: { + id: true, + name: true, + icon: { + select: { + name: true, + }, + }, + color: { + select: { + value: true, + }, + }, + }, + }, + template: { + select: { + id: true, + templateName: true, + caseFields: { + select: { + caseField: { + select: { + id: true, + defaultValue: true, + displayName: true, + type: { + select: { + type: true, + }, + }, + fieldOptions: { + select: { + fieldOption: { + select: { + id: true, + icon: true, + iconColor: true, + name: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + caseFieldValues: { + select: { + id: true, + value: true, + fieldId: true, + field: { + select: { + id: true, + displayName: true, + type: { + select: { + type: true, + }, + }, + }, + }, + }, + where: { field: { isEnabled: true, isDeleted: false } }, + }, + attachments: { + orderBy: { createdAt: "desc" }, + where: { isDeleted: false }, + }, + steps: { + where: { + isDeleted: false, + OR: [ + { sharedStepGroupId: null }, + { sharedStepGroup: { isDeleted: false } }, + ], + }, + orderBy: { order: "asc" }, + select: { + id: true, + order: true, + step: true, + expectedResult: true, + sharedStepGroupId: true, + sharedStepGroup: { + select: { + name: true, + }, + }, + }, + }, + tags: { + where: { + isDeleted: false, + }, + }, + issues: { + where: { + isDeleted: false, + }, + include: { + integration: true, + }, + }, + testRuns: { + select: { + id: true, + testRun: { + select: { + id: true, + name: true, + projectId: true, + isDeleted: true, + isCompleted: true, + }, + }, + results: { + select: { + id: true, + executedAt: true, + status: { + select: { + id: true, + name: true, + color: { + select: { + value: true, + }, + }, + }, + }, + }, + where: { + isDeleted: false, + }, + orderBy: { + executedAt: "desc", + }, + take: 1, + }, + }, + }, + linksFrom: { + select: { + caseBId: true, + type: true, + isDeleted: true, + }, + }, + linksTo: { + select: { + caseAId: true, + type: true, + isDeleted: true, + }, + }, + junitResults: { + select: { + id: true, + executedAt: true, + status: { + select: { + id: true, + name: true, + color: { + select: { + value: true, + }, + }, + }, + }, + testSuite: { + select: { + id: true, + testRun: { + select: { + id: true, + name: true, + isDeleted: true, + }, + }, + }, + }, + }, + orderBy: { + executedAt: "desc", + }, + take: 1, + }, + _count: { + select: { + comments: { + where: { + isDeleted: false, + }, + }, + }, + }, +} as const satisfies Prisma.RepositoryCasesSelect; + interface CasesProps { folderId: number | null; viewType: string; @@ -1284,6 +1515,34 @@ export default function Cases({ descendantFolderIds, ]); + // When `showDescendants` is on with a selected folder, the descendant folder + // IDs are fetched via a POST endpoint that resolves them server-side (recursive + // CTE). This avoids serializing a very large `folderId: { in: [...] }` array + // into the URL of the ZenStack GET request, which triggers HTTP 414 on deep + // folder trees. + const isDescendantsMode = + showDescendants && + folderId !== null && + folderId !== undefined && + viewType === "folders" && + !isRunMode && + (descendantFolderIds?.length ?? 0) > 0; + + // The descendants POST body omits the folder filter — the server injects it + // after resolving the subtree. + const repositoryCaseWhereClauseWithoutFolderFilter: Prisma.RepositoryCasesWhereInput = + useMemo(() => { + if (!isDescendantsMode) return repositoryCaseWhereClause; + const andList = Array.isArray(repositoryCaseWhereClause.AND) + ? (repositoryCaseWhereClause.AND as Prisma.RepositoryCasesWhereInput[]) + : repositoryCaseWhereClause.AND + ? [repositoryCaseWhereClause.AND as Prisma.RepositoryCasesWhereInput] + : []; + return { + AND: andList.filter((cond) => !("folderId" in (cond ?? {}))), + }; + }, [isDescendantsMode, repositoryCaseWhereClause]); + // Build post-fetch filters for text/link/steps operators const postFetchFilters: PostFetchFilter[] = useMemo(() => { const filters: PostFetchFilter[] = []; @@ -1743,12 +2002,15 @@ export default function Cases({ // Disable when ES search is active (data comes from POST fetch instead) searchResultIds ? false - : // Skip query if we know the selected folder has 0 cases - viewType === "folders" && selectedFolderCaseCount === 0 + : // Disable in descendants mode (count comes from the POST endpoint) + isDescendantsMode ? false - : !isRunMode && // Don't run this in run mode - ((!!session?.user && deferredSearchString.length === 0) || - deferredSearchString.length > 0) + : // Skip query if we know the selected folder has 0 cases + viewType === "folders" && selectedFolderCaseCount === 0 + ? false + : !isRunMode && // Don't run this in run mode + ((!!session?.user && deferredSearchString.length === 0) || + deferredSearchString.length > 0) ), refetchOnWindowFocus: false, // Keep previous data to prevent count from dropping to 0 during refetch @@ -1783,7 +2045,7 @@ export default function Cases({ ); // Query to fetch all case IDs when Shift+click Select All is used - const { data: allCaseIdsData } = useFindManyRepositoryCasesFiltered( + const { data: allCaseIdsDataZenStack } = useFindManyRepositoryCasesFiltered( { where: repositoryCaseWhereClause, select: { @@ -1793,11 +2055,28 @@ export default function Cases({ }, postFetchFilters.length > 0 ? postFetchFilters : undefined, { - enabled: fetchAllIdsForSelection && !isRunMode, // Don't run in run mode + enabled: fetchAllIdsForSelection && !isRunMode && !isDescendantsMode, refetchOnWindowFocus: false, } ); + // Parallel select-all-IDs fetch for descendants mode (POST to avoid 414) + const { data: allCaseIdsDataDescendants } = + useFindManyRepositoryCasesByDescendants( + { + projectId, + folderId: folderId ?? 0, + where: repositoryCaseWhereClauseWithoutFolderFilter, + select: { id: true, isDeleted: true }, + enabled: fetchAllIdsForSelection && !isRunMode && isDescendantsMode, + }, + postFetchFilters.length > 0 ? postFetchFilters : undefined + ); + + const allCaseIdsData = isDescendantsMode + ? allCaseIdsDataDescendants + : allCaseIdsDataZenStack; + const isTotalLoading = false; // Handle Shift+Click Select All/Deselect All across all pages @@ -1840,231 +2119,7 @@ export default function Cases({ { orderBy: orderBy, where: repositoryCaseWhereClause, - select: { - id: true, - projectId: true, - project: true, - creator: true, - folder: true, - repositoryId: true, - folderId: true, - templateId: true, - name: true, - stateId: true, - estimate: true, - forecastManual: true, - forecastAutomated: true, - order: true, - createdAt: true, - creatorId: true, - automated: true, - isArchived: true, - isDeleted: true, - currentVersion: true, - source: true, - state: { - select: { - id: true, - name: true, - icon: { - select: { - name: true, - }, - }, - color: { - select: { - value: true, - }, - }, - }, - }, - template: { - select: { - id: true, - templateName: true, - caseFields: { - select: { - caseField: { - select: { - id: true, - defaultValue: true, - displayName: true, - type: { - select: { - type: true, - }, - }, - fieldOptions: { - select: { - fieldOption: { - select: { - id: true, - icon: true, - iconColor: true, - name: true, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - caseFieldValues: { - select: { - id: true, - value: true, - fieldId: true, - field: { - select: { - id: true, - displayName: true, - type: { - select: { - type: true, - }, - }, - }, - }, - }, - where: { field: { isEnabled: true, isDeleted: false } }, - }, - attachments: { - orderBy: { createdAt: "desc" }, - where: { isDeleted: false }, - }, - steps: { - where: { - isDeleted: false, - OR: [ - { sharedStepGroupId: null }, - { sharedStepGroup: { isDeleted: false } }, - ], - }, - orderBy: { order: "asc" }, - select: { - id: true, - order: true, - step: true, - expectedResult: true, - sharedStepGroupId: true, - sharedStepGroup: { - select: { - name: true, - }, - }, - }, - }, - tags: { - where: { - isDeleted: false, - }, - }, - issues: { - where: { - isDeleted: false, - }, - include: { - integration: true, - }, - }, - testRuns: { - select: { - id: true, - testRun: { - select: { - id: true, - name: true, - projectId: true, - isDeleted: true, - isCompleted: true, - }, - }, - results: { - select: { - id: true, - executedAt: true, - status: { - select: { - id: true, - name: true, - color: { - select: { - value: true, - }, - }, - }, - }, - }, - where: { - isDeleted: false, - }, - orderBy: { - executedAt: "desc", - }, - take: 1, - }, - }, - }, - linksFrom: { - select: { - caseBId: true, - type: true, - isDeleted: true, - }, - }, - linksTo: { - select: { - caseAId: true, - type: true, - isDeleted: true, - }, - }, - junitResults: { - select: { - id: true, - executedAt: true, - status: { - select: { - id: true, - name: true, - color: { - select: { - value: true, - }, - }, - }, - }, - testSuite: { - select: { - id: true, - testRun: { - select: { - id: true, - name: true, - isDeleted: true, - }, - }, - }, - }, - }, - orderBy: { - executedAt: "desc", - }, - take: 1, - }, - _count: { - select: { - comments: { - where: { - isDeleted: false, - }, - }, - }, - }, - }, + select: REPOSITORY_CASE_LIST_SELECT, // When post-fetch filtering is active, fetch all data (no pagination) // Otherwise apply server-side pagination for repository mode skip: @@ -2084,12 +2139,15 @@ export default function Cases({ // Disable when ES search is active (data comes from POST fetch instead) searchResultIds ? false - : // Skip query if we know the selected folder has 0 cases - viewType === "folders" && selectedFolderCaseCount === 0 + : // Disable in descendants mode (data comes from POST endpoint) + isDescendantsMode ? false - : !isRunMode && // Don't run this query in run mode - we use testRunCasesData instead - ((!!session?.user && deferredSearchString.length === 0) || - deferredSearchString.length > 0) + : // Skip query if we know the selected folder has 0 cases + viewType === "folders" && selectedFolderCaseCount === 0 + ? false + : !isRunMode && // Don't run this query in run mode - we use testRunCasesData instead + ((!!session?.user && deferredSearchString.length === 0) || + deferredSearchString.length > 0) ), refetchOnWindowFocus: false, }, @@ -2291,12 +2349,55 @@ export default function Cases({ }; const { - data, - isLoading, - totalCount: filteredTotalCount, - refetch: refetchData, + data: zenStackData, + isLoading: zenStackIsLoading, + totalCount: zenStackFilteredTotalCount, + refetch: zenStackRefetchData, } = result; + // Descendants POST path: same shape as `result`, used when the folder subtree + // is too large to serialize into the ZenStack GET query string. + const descendantsResult = useFindManyRepositoryCasesByDescendants( + { + projectId, + folderId: folderId ?? 0, + where: repositoryCaseWhereClauseWithoutFolderFilter, + orderBy, + select: REPOSITORY_CASE_LIST_SELECT, + skip: + postFetchFilters.length > 0 + ? undefined + : (currentPage - 1) * (pageSize === "All" ? 0 : pageSize), + take: + postFetchFilters.length > 0 + ? undefined + : pageSize === "All" + ? undefined + : pageSize, + enabled: Boolean( + isDescendantsMode && !!session?.user && isValidProjectId + ), + }, + postFetchFilters.length > 0 ? postFetchFilters : undefined, + postFetchFilters.length > 0 + ? { + skip: (currentPage - 1) * (pageSize === "All" ? 0 : pageSize), + take: pageSize === "All" ? undefined : pageSize, + } + : undefined + ); + + const data = isDescendantsMode ? descendantsResult.data : zenStackData; + const isLoading = isDescendantsMode + ? descendantsResult.isLoading + : zenStackIsLoading; + const filteredTotalCount = isDescendantsMode + ? descendantsResult.totalCount + : zenStackFilteredTotalCount; + const refetchData = isDescendantsMode + ? descendantsResult.refetch + : zenStackRefetchData; + // --- ES search POST-based data fetching --- // When searchResultIds is active, fetch case data via POST to avoid URL length limits. const [searchData, setSearchData] = useState(null); @@ -2364,6 +2465,11 @@ export default function Cases({ // In run mode, use the test run cases count return testRunCasesCountData || 0; } + // In descendants mode, the count comes from the descendants POST endpoint + // (the separate count hook is disabled to avoid a duplicate 414). + if (isDescendantsMode) { + return filteredTotalCount ?? 0; + } // In repository mode, use post-fetch filtered count if available, otherwise use database count if (postFetchFilters.length > 0 && filteredTotalCount !== undefined) { return filteredTotalCount; @@ -2378,6 +2484,7 @@ export default function Cases({ viewType, selectedFolderCaseCount, searchResultIds, + isDescendantsMode, ]); // Update total items in pagination context diff --git a/testplanit/app/api/projects/[projectId]/cases/by-folder-descendants/route.ts b/testplanit/app/api/projects/[projectId]/cases/by-folder-descendants/route.ts new file mode 100644 index 00000000..f0ce2a40 --- /dev/null +++ b/testplanit/app/api/projects/[projectId]/cases/by-folder-descendants/route.ts @@ -0,0 +1,152 @@ +import { getEnhancedDb } from "@/lib/auth/utils"; +import { getServerSession } from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod/v4"; +import { prisma } from "~/lib/prisma"; +import { authOptions } from "~/server/auth"; + +// Accepts the same `where` / `orderBy` / `select` that the ZenStack +// useFindManyRepositoryCases hook would build client-side. We POST instead of +// GET so a deep folder tree (which expands into a very large `folderId: { in: [...] }` +// array) can't push the request past the HTTP 414 URI limit. +const requestSchema = z.object({ + folderId: z.number().int(), + where: z.record(z.string(), z.any()).optional(), + orderBy: z + .union([ + z.record(z.string(), z.any()), + z.array(z.record(z.string(), z.any())), + ]) + .optional(), + select: z.record(z.string(), z.any()).optional(), + skip: z.number().int().nonnegative().optional(), + take: z.number().int().nonnegative().optional(), +}); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ projectId: string }> } +) { + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { projectId: projectIdParam } = await params; + const projectId = parseInt(projectIdParam); + if (isNaN(projectId)) { + return NextResponse.json( + { error: "Invalid project ID" }, + { status: 400 } + ); + } + + // Route authorization through the ZenStack-enhanced client so RepositoryCases + // `@@allow('read')` rules (role checks, NO_ACCESS denies, defaultRole + // requirements, etc.) are enforced per-row instead of relying on a coarse + // project-level check. + const db = await getEnhancedDb(session); + + const project = await db.projects.findUnique({ + where: { id: projectId }, + }); + + if (!project || project.isDeleted) { + return NextResponse.json( + { error: "Project not found or access denied" }, + { status: 404 } + ); + } + + const body = await request.json(); + const { folderId, where, orderBy, select, skip, take } = + requestSchema.parse(body); + + // Walk the folder tree server-side. Returns the root plus every non-deleted + // descendant within the same project. Keeps the wire payload bounded to one + // folderId regardless of tree depth. Safe to use raw Prisma here: we already + // gated on project read access, and RepositoryFolders read access is + // derived from project read access (see schema.zmodel). + const descendantRows = await prisma.$queryRaw>` + WITH RECURSIVE descendants AS ( + SELECT id + FROM "RepositoryFolders" + WHERE id = ${folderId} + AND "projectId" = ${projectId} + AND "isDeleted" = false + UNION ALL + SELECT f.id + FROM "RepositoryFolders" f + INNER JOIN descendants d ON f."parentId" = d.id + WHERE f."projectId" = ${projectId} + AND f."isDeleted" = false + ) + SELECT id FROM descendants + `; + + const descendantIds = descendantRows.map((r) => r.id); + + if (descendantIds.length === 0) { + return NextResponse.json({ + cases: select ? [] : null, + totalCount: 0, + }); + } + + // Force projectId and folderId on the server so a client can't use this + // endpoint to query cases outside the authorized project or folder subtree. + const enforcedWhere = { + ...(where ?? {}), + projectId, + folderId: { in: descendantIds }, + }; + + // Count + findMany run through the enhanced client — ZenStack applies + // RepositoryCases `@@allow('read')` as a filter, so a user who lacks row + // access sees those rows elided rather than returned. + const [totalCount, cases] = await Promise.all([ + db.repositoryCases.count({ where: enforcedWhere }), + select + ? db.repositoryCases.findMany({ + where: enforcedWhere, + orderBy: orderBy as never, + select: select as never, + skip, + take, + }) + : Promise.resolve(null), + ]); + + const serializedCases = cases + ? cases.map((c: Record) => { + const attachments = ( + c as { attachments?: Array> } + ).attachments; + if (!attachments) return c; + return { + ...c, + attachments: attachments.map((a) => ({ + ...a, + size: typeof a.size === "bigint" ? a.size.toString() : a.size, + })), + }; + }) + : null; + + return NextResponse.json({ cases: serializedCases, totalCount }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: "Invalid request data", details: error.issues }, + { status: 400 } + ); + } + + console.error("Error fetching cases by folder descendants:", error); + return NextResponse.json( + { error: "Failed to fetch cases" }, + { status: 500 } + ); + } +} diff --git a/testplanit/hooks/useRepositoryCasesByDescendants.ts b/testplanit/hooks/useRepositoryCasesByDescendants.ts new file mode 100644 index 00000000..83d82b5d --- /dev/null +++ b/testplanit/hooks/useRepositoryCasesByDescendants.ts @@ -0,0 +1,163 @@ +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { + filterOrphanedFieldValues, + matchesLinkOperator, + matchesStepsOperator, + matchesTextOperator, + type PostFetchFilter, +} from "./useRepositoryCasesWithFilteredFields"; + +interface Params { + projectId: number; + folderId: number; + where?: unknown; + orderBy?: unknown; + select?: unknown; + skip?: number; + take?: number; + enabled?: boolean; +} + +// Parallel to useFindManyRepositoryCasesFiltered but fetches via POST so a deep +// folder tree doesn't blow up the URI length. The server resolves descendant +// folder IDs from the single root folderId via a recursive CTE. +export function useFindManyRepositoryCasesByDescendants( + params: Params, + postFetchFilters?: PostFetchFilter[], + clientPagination?: { skip: number; take: number | undefined } +) { + const { + projectId, + folderId, + where, + orderBy, + select, + skip, + take, + enabled = true, + } = params; + + const query = useQuery({ + queryKey: [ + "repositoryCasesByDescendants", + projectId, + folderId, + where, + orderBy, + select, + skip, + take, + ], + enabled, + queryFn: async () => { + const res = await fetch( + `/api/projects/${projectId}/cases/by-folder-descendants`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + folderId, + where, + orderBy, + select, + skip, + take, + }), + } + ); + if (!res.ok) { + throw new Error( + `Failed to fetch repository cases by descendants (status ${res.status})` + ); + } + return res.json() as Promise<{ + cases: unknown[] | null; + totalCount: number; + }>; + }, + placeholderData: (previousData) => previousData, + refetchOnWindowFocus: false, + }); + + const resultCases = query.data?.cases as any[] | null | undefined; + const resultTotalCount = query.data?.totalCount; + + const { filteredCases, totalFilteredCount } = useMemo(() => { + if (!resultCases || !Array.isArray(resultCases)) { + return { + filteredCases: resultCases ?? undefined, + totalFilteredCount: resultTotalCount ?? 0, + }; + } + + let cases = resultCases.map(filterOrphanedFieldValues); + + if (postFetchFilters && postFetchFilters.length > 0) { + cases = cases.filter((testCase: any) => { + for (const filter of postFetchFilters) { + const fieldValue = testCase.caseFieldValues?.find( + (cfv: any) => cfv.fieldId === filter.fieldId + ); + let matches = false; + if (filter.type === "text" && typeof filter.value1 === "string") { + matches = matchesTextOperator( + fieldValue?.value, + filter.operator, + filter.value1 + ); + } else if ( + filter.type === "link" && + typeof filter.value1 === "string" + ) { + matches = matchesLinkOperator( + fieldValue?.value, + filter.operator, + filter.value1 + ); + } else if ( + filter.type === "steps" && + typeof filter.value1 === "number" + ) { + matches = matchesStepsOperator( + testCase, + filter.operator, + filter.value1, + filter.value2 + ); + } + if (!matches) return false; + } + return true; + }); + } + + const totalCount = + postFetchFilters && postFetchFilters.length > 0 + ? cases.length + : (resultTotalCount ?? 0); + + return { filteredCases: cases, totalFilteredCount: totalCount }; + }, [resultCases, resultTotalCount, postFetchFilters]); + + const paginatedData = useMemo(() => { + if (!filteredCases || !Array.isArray(filteredCases)) return filteredCases; + if (clientPagination && clientPagination.skip !== undefined) { + const start = clientPagination.skip; + const end = clientPagination.take + ? start + clientPagination.take + : undefined; + return filteredCases.slice(start, end); + } + return filteredCases; + }, [filteredCases, clientPagination]); + + return { + data: paginatedData, + isLoading: query.isLoading, + isFetching: query.isFetching, + error: query.error, + refetch: query.refetch, + totalCount: totalFilteredCount, + }; +}