diff --git a/app/api/tina/rules-by-author/route.ts b/app/api/tina/rules-by-author/route.ts index e840a1bb9..7be30a737 100644 --- a/app/api/tina/rules-by-author/route.ts +++ b/app/api/tina/rules-by-author/route.ts @@ -5,19 +5,20 @@ export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url); const authorTitle = searchParams.get("authorTitle"); - const firstStr = searchParams.get("first"); - const after = searchParams.get("after") || undefined; + const lastStr = searchParams.get("last") ?? searchParams.get("first"); + const before = searchParams.get("before") || undefined; if (!authorTitle) { return NextResponse.json({ error: "authorTitle is required" }, { status: 400 }); } - const first = firstStr ? Number(firstStr) : undefined; + const last = lastStr ? Number(lastStr) : undefined; const result = await client.queries.rulesByAuthor({ authorTitle, - first, - after, + last, + before, + sort: "created", }); return NextResponse.json(result, { status: 200 }); diff --git a/app/user/client-page.tsx b/app/user/client-page.tsx index 0911a1645..839811f85 100644 --- a/app/user/client-page.tsx +++ b/app/user/client-page.tsx @@ -30,13 +30,12 @@ export default function UserRulesClientPage({ ruleCount }) { const [authoredRules, setAuthoredRules] = useState([]); const [author, setAuthor] = useState<{ fullName?: string; slug?: string; gitHubUrl?: string }>({}); const [loadingAuthored, setLoadingAuthored] = useState(false); - const [authoredNextCursor, setAuthoredNextCursor] = useState(null); - const [authoredHasNext, setAuthoredHasNext] = useState(false); + const [authoredFullyLoaded, setAuthoredFullyLoaded] = useState(false); const [loadingMoreAuthored, setLoadingMoreAuthored] = useState(false); const [githubError, setGithubError] = useState(null); const [currentPageAuthored, setCurrentPageAuthored] = useState(1); const [itemsPerPageAuthored, setItemsPerPageAuthored] = useState(20); - const FETCH_PAGE_SIZE = 10; + const FETCH_PAGE_SIZE = 20; const resolveAuthor = async (): Promise => { const res = await fetch(`./api/crm/employees?query=${encodeURIComponent(queryStringRulesAuthor)}`); @@ -97,26 +96,33 @@ export default function UserRulesClientPage({ ruleCount }) { } }; - // Function to load ALL authored rules (not just one page) - WITH BATCHING + // Fetch authored rules progressively: + // - render once the first page returns + // - keep fetching and appending in the background + // - only enable pagination UI once all results are fetched const loadAllAuthoredRules = async (authorName: string) => { setLoadingAuthored(true); + setLoadingMoreAuthored(false); + setAuthoredFullyLoaded(false); setAuthoredRules([]); + setCurrentPageAuthored(1); + let cursor: string | null = null; - let previousCursor: string | null = null; let hasMore = true; let pageCount = 0; const MAX_PAGES = 100; // Safety limit to prevent infinite loops const allRulesFromTina: any[] = []; + const seenKeys = new Set(); + try { - // Step 1: Fetch ALL pages from TinaCMS API (collect rules) while (hasMore && pageCount < MAX_PAGES) { pageCount++; const params = new URLSearchParams(); params.set("authorTitle", authorName || ""); - params.set("first", FETCH_PAGE_SIZE.toString()); - if (cursor) params.set("after", cursor); + params.set("last", FETCH_PAGE_SIZE.toString()); + if (cursor) params.set("before", cursor); const tinaRes = await fetch(`./api/tina/rules-by-author?${params.toString()}`); if (!tinaRes.ok) { @@ -127,52 +133,63 @@ export default function UserRulesClientPage({ ruleCount }) { const edges = res?.data?.ruleConnection?.edges ?? []; const nodes = edges.map((e: any) => e?.node).filter(Boolean); - const batch = nodes.map((fullRule: any) => ({ - guid: fullRule.guid, - title: fullRule.title, - uri: fullRule.uri, - body: fullRule.body, - authors: fullRule.authors?.map((a: any) => (a && a.title ? { title: a.title } : null)).filter((a: any): a is { title: string } => a !== null) || [], - lastUpdated: fullRule.lastUpdated, - lastUpdatedBy: fullRule.lastUpdatedBy, - })); + const batch = nodes + .map((fullRule: any) => ({ + guid: fullRule.guid, + title: fullRule.title, + uri: fullRule.uri, + body: fullRule.body, + authors: fullRule.authors?.map((a: any) => (a && a.title ? { title: a.title } : null)).filter((a: any): a is { title: string } => a !== null) || [], + lastUpdated: fullRule.lastUpdated, + lastUpdatedBy: fullRule.lastUpdatedBy, + })) + .filter((r: any) => { + const key = r.guid || r.uri; + if (!key) return true; + if (seenKeys.has(key)) return false; + seenKeys.add(key); + return true; + }); allRulesFromTina.push(...batch); + // Render as soon as we have the first page. + // Backend returns results sorted by created, so we can append without re-sorting. + setAuthoredRules([...allRulesFromTina]); + if (pageCount === 1) { + setLoadingAuthored(false); + } else { + setLoadingMoreAuthored(true); + } + const pageInfo = res?.data?.ruleConnection?.pageInfo; - const newCursor = pageInfo?.endCursor ?? null; - const hasMorePages = !!pageInfo?.hasNextPage; + const newCursor = pageInfo?.startCursor ?? null; + const hasMorePages = !!pageInfo?.hasPreviousPage; // Stop if cursor hasn't changed (prevents infinite loop) - if (newCursor === previousCursor && previousCursor !== null) { + if (newCursor === cursor && cursor !== null) { break; } - previousCursor = cursor; cursor = newCursor; hasMore = hasMorePages && newCursor !== null; } - - // Step 2: Sort by date (most recent first) and set all at once - const sortedRules = allRulesFromTina.sort((a, b) => { - const dateA = a.lastUpdated ? new Date(a.lastUpdated).getTime() : 0; - const dateB = b.lastUpdated ? new Date(b.lastUpdated).getTime() : 0; - return dateB - dateA; // Descending order (newest first) - }); - - setAuthoredRules(sortedRules); } catch (err) { console.error("Failed to fetch authored rules:", err); } finally { + setAuthoredFullyLoaded(true); + setLoadingMoreAuthored(false); setLoadingAuthored(false); + setItemsPerPageAuthored(20); + setCurrentPageAuthored(1); } }; const TabHeader = () => (
{[ - { key: Tabs.AUTHORED, label: `Authored (${authoredRules.length})` }, - { key: Tabs.MODIFIED, label: `Last Modified (${lastModifiedRules.length})` }, + { key: Tabs.AUTHORED, label: authoredFullyLoaded ? `Authored (${authoredRules.length})` : "Authored" }, + { key: Tabs.MODIFIED, label: loadingLastModified ? "Last Modified" : `Last Modified (${lastModifiedRules.length})` }, ].map((t, i) => { const isActive = activeTab === t.key; return ( @@ -221,6 +238,7 @@ export default function UserRulesClientPage({ ruleCount }) { const totalPagesAuthored = itemsPerPageAuthored >= authoredRules.length ? 1 : Math.ceil(authoredRules.length / itemsPerPageAuthored); const paginatedAuthoredRules = useMemo(() => { + // Keep list height stable: always render the current page slice (even while background loading). if (itemsPerPageAuthored >= authoredRules.length) { return authoredRules; } @@ -303,7 +321,7 @@ export default function UserRulesClientPage({ ruleCount }) { <> {authoredRules.length === 0 && loadingAuthored ? (
- +
) : authoredRules.length === 0 ? (
No rules found.
@@ -316,14 +334,20 @@ export default function UserRulesClientPage({ ruleCount }) { externalCurrentPage={currentPageAuthored} externalItemsPerPage={itemsPerPageAuthored} /> - + {!authoredFullyLoaded && loadingMoreAuthored && ( +
Loading more authored rules...
+ )} + + {authoredFullyLoaded && ( + + )} )} diff --git a/tina/queries/queries.gql b/tina/queries/queries.gql index d6f7cc62f..1bec7e40a 100644 --- a/tina/queries/queries.gql +++ b/tina/queries/queries.gql @@ -150,11 +150,11 @@ query rulesByUriQuery($uris: [String!]!) { } } -query rulesByAuthor($authorTitle: String!, $first: Float, $after: String) { - ruleConnection(filter: { authors: { title: { eq: $authorTitle } } }, first: $first, after: $after) { +query rulesByAuthor($authorTitle: String!, $last: Float, $before: String, $sort: String) { + ruleConnection(filter: { authors: { title: { eq: $authorTitle } } }, last: $last, before: $before, sort: $sort) { pageInfo { - hasNextPage - endCursor + hasPreviousPage + startCursor } edges { node {