diff --git a/backend/migrations/20260726_01_tabular_reviews_pagination.sql b/backend/migrations/20260726_01_tabular_reviews_pagination.sql new file mode 100644 index 000000000..5746652ef --- /dev/null +++ b/backend/migrations/20260726_01_tabular_reviews_pagination.sql @@ -0,0 +1,326 @@ +-- Migration date: 2026-07-26 + +-- Performance pass on get_tabular_reviews_overview: +-- 1. Compute document_count once (previously recomputed in the select list and +-- twice more in the `documents` sort case branches) and reuse the value. +-- 2. Narrow cell_document_counts to only the reviews that will actually use it +-- (document_ids is not a jsonb array), instead of aggregating tabular_cells +-- for every visible review regardless of whether the result gets used. +-- 3. Add a trigram index so the leading-wildcard title search can use an index +-- instead of a full scan of the pre-filtered set. + +create extension if not exists pg_trgm; + +create index if not exists tabular_reviews_title_trgm_idx + on public.tabular_reviews using gin (lower(title) gin_trgm_ops); + +-- Remove the earlier paginated signature if this migration was tested before +-- scope filtering was added. The legacy three-argument signature is retained +-- below as a non-ambiguous compatibility wrapper. +drop function if exists public.get_tabular_reviews_overview( + text, text, text, integer, integer, text, text, text +); + +create or replace function public.get_tabular_reviews_overview( + p_user_id text, + p_user_email text, + p_project_id text, + p_scope text, + p_limit integer, + p_offset integer, + p_search_term text, + p_sort_key text, + p_sort_direction text +) +returns table ( + id uuid, + project_id uuid, + user_id text, + title text, + columns_config jsonb, + document_ids jsonb, + workflow_id uuid, + shared_with jsonb, + created_at timestamptz, + updated_at timestamptz, + is_owner boolean, + document_count integer +) +language sql +stable +as $$ + with accessible_projects as ( + select p.id + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + ), + visible_reviews as ( + select tr.* + from public.tabular_reviews tr + where (p_project_id is null or tr.project_id::text = p_project_id) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'in-project' and tr.project_id is not null) + or (p_scope = 'standalone' and tr.project_id is null) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(tr.title) like + '%' || + replace( + replace( + replace(lower(p_search_term), '\', '\\'), + '%', + '\%' + ), + '_', + '\_' + ) || + '%' + escape '\' + ) + and ( + p_project_id is null + or exists ( + select 1 + from accessible_projects ap + where ap.id::text = p_project_id + ) + ) + and ( + tr.user_id = p_user_id + or ( + tr.project_id in (select ap.id from accessible_projects ap) + and tr.user_id <> p_user_id + ) + or ( + p_project_id is null + and coalesce(p_user_email, '') <> '' + and tr.user_id <> p_user_id + and tr.shared_with @> jsonb_build_array(p_user_email) + ) + ) + ), + cell_document_counts as ( + select + tc.review_id, + count(distinct tc.document_id)::integer as document_count + from public.tabular_cells tc + where tc.review_id in ( + select vr.id + from visible_reviews vr + where jsonb_typeof(vr.document_ids) is distinct from 'array' + ) + group by tc.review_id + ), + review_document_counts as ( + select + vr.id, + case + when jsonb_typeof(vr.document_ids) = 'array' + then ( + select count(distinct doc_id.value)::integer + from jsonb_array_elements_text(vr.document_ids) as doc_id(value) + ) + else coalesce(cdc.document_count, 0) + end as document_count + from visible_reviews vr + left join cell_document_counts cdc + on cdc.review_id = vr.id + ) + select + vr.id, + vr.project_id, + vr.user_id, + vr.title, + vr.columns_config, + vr.document_ids, + vr.workflow_id, + vr.shared_with, + vr.created_at, + vr.updated_at, + vr.user_id = p_user_id as is_owner, + rdc.document_count + from visible_reviews vr + join review_document_counts rdc + on rdc.id = vr.id + order by + case + when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vr.title, '')) + else null + end asc, + case + when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vr.title, '')) + else null + end desc, + case + when p_sort_key = 'columns' and p_sort_direction = 'asc' then jsonb_array_length(coalesce(vr.columns_config, '[]'::jsonb)) + else null + end asc, + case + when p_sort_key = 'columns' and p_sort_direction = 'desc' then jsonb_array_length(coalesce(vr.columns_config, '[]'::jsonb)) + else null + end desc, + case + when p_sort_key = 'documents' and p_sort_direction = 'asc' then rdc.document_count + else null + end asc, + case + when p_sort_key = 'documents' and p_sort_direction = 'desc' then rdc.document_count + else null + end desc, + case + when p_sort_key = 'created' and p_sort_direction = 'asc' then vr.created_at + else null + end asc, + case + when p_sort_key = 'created' and p_sort_direction = 'desc' then vr.created_at + else null + end desc, + vr.created_at desc, + vr.id asc + limit greatest(coalesce(p_limit, 20), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +-- Preserve the pre-pagination RPC contract without making PostgREST choose +-- between two functions that can both accept the same three arguments. +create or replace function public.get_tabular_reviews_overview( + p_user_id text, + p_user_email text default null, + p_project_id text default null +) +returns table ( + id uuid, + project_id uuid, + user_id text, + title text, + columns_config jsonb, + document_ids jsonb, + workflow_id uuid, + shared_with jsonb, + created_at timestamptz, + updated_at timestamptz, + is_owner boolean, + document_count integer +) +language sql +stable +as $$ + select * + from public.get_tabular_reviews_overview( + p_user_id, + p_user_email, + p_project_id, + 'all', + 2147483647, + 0, + null, + 'created', + 'desc' + ); +$$; + +-- Lightweight companion to get_tabular_reviews_overview for bulk "select +-- all matching" actions. A caller here only needs id + owning user, not a +-- full review payload — so this does NOT delegate to +-- get_tabular_reviews_overview: that RPC's cell_document_counts / +-- review_document_counts CTEs join and aggregate over tabular_cells for +-- every visible review just to compute document_count, which is pure waste +-- when the caller is going to discard everything but id/user_id anyway. +-- Instead this filters tabular_reviews directly with the same +-- visibility/scope/search predicate as the visible_reviews CTE above. +-- +-- NOTE: that predicate is duplicated, not shared — SQL has no clean way to +-- factor a CTE across two function definitions. If the access/visibility +-- rules in get_tabular_reviews_overview's visible_reviews CTE ever change, +-- mirror the change here too. +-- +-- Takes p_limit/p_offset (unlike a "give me everything" design) because +-- PostgREST enforces its own db-max-rows cap on every RPC response +-- regardless of what this function returns — a caller that doesn't page +-- through results will silently get a truncated set with no error. The +-- backend route pages through this on the caller's behalf. + +create or replace function public.get_tabular_review_ids_overview( + p_user_id text, + p_user_email text, + p_project_id text, + p_scope text, + p_search_term text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, + user_id text +) +language sql +stable +as $$ + with accessible_projects as ( + select p.id + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + ) + select tr.id, tr.user_id + from public.tabular_reviews tr + where (p_project_id is null or tr.project_id::text = p_project_id) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'in-project' and tr.project_id is not null) + or (p_scope = 'standalone' and tr.project_id is null) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(tr.title) like + '%' || + replace( + replace( + replace(lower(p_search_term), '\', '\\'), + '%', + '\%' + ), + '_', + '\_' + ) || + '%' + escape '\' + ) + and ( + p_project_id is null + or exists ( + select 1 + from accessible_projects ap + where ap.id::text = p_project_id + ) + ) + and ( + tr.user_id = p_user_id + or ( + tr.project_id in (select ap.id from accessible_projects ap) + and tr.user_id <> p_user_id + ) + or ( + p_project_id is null + and coalesce(p_user_email, '') <> '' + and tr.user_id <> p_user_id + and tr.shared_with @> jsonb_build_array(p_user_email) + ) + ) + order by tr.created_at desc, tr.id asc + limit greatest(coalesce(p_limit, 1000), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; diff --git a/backend/migrations/20260727_01_tabular_review_ids_overview.sql b/backend/migrations/20260727_01_tabular_review_ids_overview.sql new file mode 100644 index 000000000..06af9c1e8 --- /dev/null +++ b/backend/migrations/20260727_01_tabular_review_ids_overview.sql @@ -0,0 +1,103 @@ +-- Migration date: 2026-07-27 + +-- Lightweight companion to get_tabular_reviews_overview for bulk "select +-- all matching" actions. A caller here only needs id + owning user, not a +-- full review payload — so this does NOT delegate to +-- get_tabular_reviews_overview: that RPC's cell_document_counts / +-- review_document_counts CTEs join and aggregate over tabular_cells for +-- every visible review just to compute document_count, which is pure waste +-- when the caller is going to discard everything but id/user_id anyway. +-- Instead this filters tabular_reviews directly with the same +-- visibility/scope/search predicate as the visible_reviews CTE there. +-- +-- NOTE: that predicate is duplicated, not shared — SQL has no clean way to +-- factor a CTE across two function definitions. If the access/visibility +-- rules in get_tabular_reviews_overview's visible_reviews CTE ever change, +-- mirror the change here too. +-- +-- Takes p_limit/p_offset (unlike a "give me everything" design) because +-- PostgREST enforces its own db-max-rows cap on every RPC response +-- regardless of what this function returns — a caller that doesn't page +-- through results will silently get a truncated set with no error. The +-- backend route pages through this on the caller's behalf. + +drop function if exists public.get_tabular_review_ids_overview( + text, text, text, text, text +); + +create or replace function public.get_tabular_review_ids_overview( + p_user_id text, + p_user_email text, + p_project_id text, + p_scope text, + p_search_term text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, + user_id text +) +language sql +stable +as $$ + with accessible_projects as ( + select p.id + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + ) + select tr.id, tr.user_id + from public.tabular_reviews tr + where (p_project_id is null or tr.project_id::text = p_project_id) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'in-project' and tr.project_id is not null) + or (p_scope = 'standalone' and tr.project_id is null) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(tr.title) like + '%' || + replace( + replace( + replace(lower(p_search_term), '\', '\\'), + '%', + '\%' + ), + '_', + '\_' + ) || + '%' + escape '\' + ) + and ( + p_project_id is null + or exists ( + select 1 + from accessible_projects ap + where ap.id::text = p_project_id + ) + ) + and ( + tr.user_id = p_user_id + or ( + tr.project_id in (select ap.id from accessible_projects ap) + and tr.user_id <> p_user_id + ) + or ( + p_project_id is null + and coalesce(p_user_email, '') <> '' + and tr.user_id <> p_user_id + and tr.shared_with @> jsonb_build_array(p_user_email) + ) + ) + order by tr.created_at desc, tr.id asc + limit greatest(coalesce(p_limit, 1000), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; diff --git a/backend/schema.sql b/backend/schema.sql index 6819473b9..c59647c7c 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -4,6 +4,7 @@ -- newer than the version of Mike they currently have deployed. create extension if not exists "pgcrypto"; +create extension if not exists "pg_trgm"; -- --------------------------------------------------------------------------- -- User profiles @@ -602,6 +603,9 @@ create index if not exists idx_tabular_reviews_project create index if not exists tabular_reviews_shared_with_idx on public.tabular_reviews using gin (shared_with); +create index if not exists tabular_reviews_title_trgm_idx + on public.tabular_reviews using gin (lower(title) gin_trgm_ops); + create or replace function public.get_projects_overview( p_user_id text, p_user_email text default null @@ -694,10 +698,20 @@ create table if not exists public.tabular_cells ( create index if not exists idx_tabular_cells_review on public.tabular_cells(review_id, document_id, column_index); +drop function if exists public.get_tabular_reviews_overview( + text, text, text, integer, integer, text, text, text +); + create or replace function public.get_tabular_reviews_overview( p_user_id text, - p_user_email text default null, - p_project_id text default null + p_user_email text, + p_project_id text, + p_scope text, + p_limit integer, + p_offset integer, + p_search_term text, + p_sort_key text, + p_sort_direction text ) returns table ( id uuid, @@ -730,6 +744,28 @@ as $$ select tr.* from public.tabular_reviews tr where (p_project_id is null or tr.project_id::text = p_project_id) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'in-project' and tr.project_id is not null) + or (p_scope = 'standalone' and tr.project_id is null) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(tr.title) like + '%' || + replace( + replace( + replace(lower(p_search_term), '\', '\\'), + '%', + '\%' + ), + '_', + '\_' + ) || + '%' + escape '\' + ) and ( p_project_id is null or exists ( @@ -757,8 +793,27 @@ as $$ tc.review_id, count(distinct tc.document_id)::integer as document_count from public.tabular_cells tc - where tc.review_id in (select vr.id from visible_reviews vr) + where tc.review_id in ( + select vr.id + from visible_reviews vr + where jsonb_typeof(vr.document_ids) is distinct from 'array' + ) group by tc.review_id + ), + review_document_counts as ( + select + vr.id, + case + when jsonb_typeof(vr.document_ids) = 'array' + then ( + select count(distinct doc_id.value)::integer + from jsonb_array_elements_text(vr.document_ids) as doc_id(value) + ) + else coalesce(cdc.document_count, 0) + end as document_count + from visible_reviews vr + left join cell_document_counts cdc + on cdc.review_id = vr.id ) select vr.id, @@ -772,18 +827,160 @@ as $$ vr.created_at, vr.updated_at, vr.user_id = p_user_id as is_owner, - case - when jsonb_typeof(vr.document_ids) = 'array' - then ( - select count(distinct doc_id.value)::integer - from jsonb_array_elements_text(vr.document_ids) as doc_id(value) - ) - else coalesce(cdc.document_count, 0) - end as document_count + rdc.document_count from visible_reviews vr - left join cell_document_counts cdc - on cdc.review_id = vr.id - order by vr.created_at desc; + join review_document_counts rdc + on rdc.id = vr.id + order by + case + when p_sort_key = 'name' and p_sort_direction = 'asc' then lower(coalesce(vr.title, '')) + else null + end asc, + case + when p_sort_key = 'name' and p_sort_direction = 'desc' then lower(coalesce(vr.title, '')) + else null + end desc, + case + when p_sort_key = 'columns' and p_sort_direction = 'asc' then jsonb_array_length(coalesce(vr.columns_config, '[]'::jsonb)) + else null + end asc, + case + when p_sort_key = 'columns' and p_sort_direction = 'desc' then jsonb_array_length(coalesce(vr.columns_config, '[]'::jsonb)) + else null + end desc, + case + when p_sort_key = 'documents' and p_sort_direction = 'asc' then rdc.document_count + else null + end asc, + case + when p_sort_key = 'documents' and p_sort_direction = 'desc' then rdc.document_count + else null + end desc, + case + when p_sort_key = 'created' and p_sort_direction = 'asc' then vr.created_at + else null + end asc, + case + when p_sort_key = 'created' and p_sort_direction = 'desc' then vr.created_at + else null + end desc, + vr.created_at desc, + vr.id asc + limit greatest(coalesce(p_limit, 20), 1) + offset greatest(coalesce(p_offset, 0), 0); +$$; + +create or replace function public.get_tabular_reviews_overview( + p_user_id text, + p_user_email text default null, + p_project_id text default null +) +returns table ( + id uuid, + project_id uuid, + user_id text, + title text, + columns_config jsonb, + document_ids jsonb, + workflow_id uuid, + shared_with jsonb, + created_at timestamptz, + updated_at timestamptz, + is_owner boolean, + document_count integer +) +language sql +stable +as $$ + select * + from public.get_tabular_reviews_overview( + p_user_id, + p_user_email, + p_project_id, + 'all', + 2147483647, + 0, + null, + 'created', + 'desc' + ); +$$; + +create or replace function public.get_tabular_review_ids_overview( + p_user_id text, + p_user_email text, + p_project_id text, + p_scope text, + p_search_term text, + p_limit integer, + p_offset integer +) +returns table ( + id uuid, + user_id text +) +language sql +stable +as $$ + with accessible_projects as ( + select p.id + from public.projects p + where p.user_id = p_user_id + or ( + coalesce(p_user_email, '') <> '' + and p.user_id <> p_user_id + and p.shared_with @> jsonb_build_array(p_user_email) + ) + ) + select tr.id, tr.user_id + from public.tabular_reviews tr + where (p_project_id is null or tr.project_id::text = p_project_id) + and ( + coalesce(p_scope, 'all') = 'all' + or (p_scope = 'in-project' and tr.project_id is not null) + or (p_scope = 'standalone' and tr.project_id is null) + ) + and ( + p_search_term is null + or p_search_term = '' + or lower(tr.title) like + '%' || + replace( + replace( + replace(lower(p_search_term), '\', '\\'), + '%', + '\%' + ), + '_', + '\_' + ) || + '%' + escape '\' + ) + and ( + p_project_id is null + or exists ( + select 1 + from accessible_projects ap + where ap.id::text = p_project_id + ) + ) + and ( + tr.user_id = p_user_id + or ( + tr.project_id in (select ap.id from accessible_projects ap) + and tr.user_id <> p_user_id + ) + or ( + p_project_id is null + and coalesce(p_user_email, '') <> '' + and tr.user_id <> p_user_id + and tr.shared_with @> jsonb_build_array(p_user_email) + ) + ) + order by tr.created_at desc, tr.id asc + limit greatest(coalesce(p_limit, 1000), 1) + offset greatest(coalesce(p_offset, 0), 0); $$; create table if not exists public.tabular_review_chats ( diff --git a/backend/scripts/test-stack.sh b/backend/scripts/test-stack.sh index f75deaef3..b5bb6a3f1 100755 --- a/backend/scripts/test-stack.sh +++ b/backend/scripts/test-stack.sh @@ -60,4 +60,5 @@ cd "$BACKEND_DIR" exec npx vitest run \ src/__tests__/integration/stack.supabase.test.ts \ src/__tests__/integration/access.supabase.test.ts \ + src/__tests__/integration/tabularPagination.supabase.test.ts \ "$@" diff --git a/backend/src/__tests__/integration/tabularPagination.supabase.test.ts b/backend/src/__tests__/integration/tabularPagination.supabase.test.ts new file mode 100644 index 000000000..34c16ab98 --- /dev/null +++ b/backend/src/__tests__/integration/tabularPagination.supabase.test.ts @@ -0,0 +1,299 @@ +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const url = process.env.SUPABASE_TEST_URL; +const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY; +const maybeDescribe = url && serviceKey ? describe : describe.skip; + +maybeDescribe("Supabase tabular-review pagination", () => { + const ownerId = crypto.randomUUID(); + const ownerEmail = `pagination-${ownerId}@test.local`; + const projectId = crypto.randomUUID(); + const projectReviewIds = Array.from({ length: 25 }, () => + crypto.randomUUID(), + ); + const standaloneReviewIds = Array.from({ length: 5 }, () => + crypto.randomUUID(), + ); + const tiedCreatedAt = "2026-07-27T00:00:00.000Z"; + let admin: SupabaseClient; + + beforeAll(async () => { + admin = createClient(url!, serviceKey!, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + const project = await admin.from("projects").insert({ + id: projectId, + user_id: ownerId, + name: "Pagination integration project", + }); + if (project.error) throw project.error; + + const projectReviews = await admin.from("tabular_reviews").insert( + projectReviewIds.map((id, index) => ({ + id, + project_id: projectId, + user_id: ownerId, + title: "Needle Review", + columns_config: Array.from( + { length: index % 5 }, + (_, columnIndex) => ({ + index: columnIndex, + name: `Column ${columnIndex}`, + prompt: `Prompt ${columnIndex}`, + }), + ), + document_ids: [], + created_at: tiedCreatedAt, + updated_at: tiedCreatedAt, + })), + ); + if (projectReviews.error) throw projectReviews.error; + + const standaloneReviews = await admin.from("tabular_reviews").insert( + standaloneReviewIds.map((id) => ({ + id, + user_id: ownerId, + title: "Standalone Needle", + columns_config: [], + document_ids: [], + created_at: tiedCreatedAt, + updated_at: tiedCreatedAt, + })), + ); + if (standaloneReviews.error) throw standaloneReviews.error; + }); + + afterAll(async () => { + if (!admin) return; + await admin + .from("tabular_reviews") + .delete() + .in("id", standaloneReviewIds); + await admin.from("projects").delete().eq("id", projectId); + }); + + it("paginates tied rows deterministically without duplicates", async () => { + const commonArgs = { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: projectId, + p_scope: "in-project", + p_search_term: "needle", + p_sort_key: "name", + p_sort_direction: "asc", + }; + const firstPage = await admin.rpc("get_tabular_reviews_overview", { + ...commonArgs, + p_limit: 20, + p_offset: 0, + }); + const secondPage = await admin.rpc("get_tabular_reviews_overview", { + ...commonArgs, + p_limit: 20, + p_offset: 20, + }); + + expect(firstPage.error).toBeNull(); + expect(secondPage.error).toBeNull(); + expect(firstPage.data).toHaveLength(20); + expect(secondPage.data).toHaveLength(5); + + const firstIds = (firstPage.data ?? []).map((row) => row.id); + const secondIds = (secondPage.data ?? []).map((row) => row.id); + expect(new Set([...firstIds, ...secondIds]).size).toBe(25); + expect([...firstIds, ...secondIds]).toEqual( + [...projectReviewIds].sort(), + ); + }); + + it("filters by scope alone (no project_id) across every accessible project", async () => { + // This is the request the "In Project" / "Standalone" tabs on the + // global tabular-reviews list send: a scope with no project_id, so + // it must filter across every project the user can see rather than + // just the one seeded project. + const inProject = await admin.rpc("get_tabular_reviews_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "in-project", + p_limit: 100, + p_offset: 0, + p_search_term: "needle", + p_sort_key: "created", + p_sort_direction: "desc", + }); + const standalone = await admin.rpc("get_tabular_reviews_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "standalone", + p_limit: 100, + p_offset: 0, + p_search_term: "needle", + p_sort_key: "created", + p_sort_direction: "desc", + }); + + expect(inProject.error).toBeNull(); + expect(standalone.error).toBeNull(); + + const inProjectIds = new Set( + (inProject.data ?? []).map((row) => row.id as string), + ); + const standaloneIds = new Set( + (standalone.data ?? []).map((row) => row.id as string), + ); + + for (const id of projectReviewIds) expect(inProjectIds.has(id)).toBe(true); + for (const id of standaloneReviewIds) + expect(inProjectIds.has(id)).toBe(false); + + for (const id of standaloneReviewIds) + expect(standaloneIds.has(id)).toBe(true); + for (const id of projectReviewIds) + expect(standaloneIds.has(id)).toBe(false); + + expect( + (inProject.data ?? []).every((row) => row.project_id !== null), + ).toBe(true); + expect( + (standalone.data ?? []).every((row) => row.project_id === null), + ).toBe(true); + }); + + it("applies scope and search before limiting rows", async () => { + const result = await admin.rpc("get_tabular_reviews_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "standalone", + p_limit: 100, + p_offset: 0, + p_search_term: "standalone needle", + p_sort_key: "created", + p_sort_direction: "desc", + }); + + expect(result.error).toBeNull(); + expect(result.data).toHaveLength(5); + expect( + (result.data ?? []).every((row) => row.project_id === null), + ).toBe(true); + }); + + it.each(["%", "_"])( + "treats %s as a literal search character", + async (searchTerm) => { + const reviews = await admin.rpc("get_tabular_reviews_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "all", + p_limit: 100, + p_offset: 0, + p_search_term: searchTerm, + p_sort_key: "created", + p_sort_direction: "desc", + }); + const ids = await admin.rpc("get_tabular_review_ids_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "all", + p_search_term: searchTerm, + p_limit: 100, + p_offset: 0, + }); + + expect(reviews.error).toBeNull(); + expect(ids.error).toBeNull(); + expect(reviews.data).toEqual([]); + expect(ids.data).toEqual([]); + }, + ); + + it("sorts the complete filtered set before pagination", async () => { + const result = await admin.rpc("get_tabular_reviews_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: projectId, + p_scope: "in-project", + p_limit: 25, + p_offset: 0, + p_search_term: null, + p_sort_key: "columns", + p_sort_direction: "asc", + }); + + expect(result.error).toBeNull(); + const columnCounts = (result.data ?? []).map( + (row) => + (row.columns_config as unknown[] | null | undefined)?.length ?? + 0, + ); + expect(columnCounts).toEqual([...columnCounts].sort((a, b) => a - b)); + }); + + it("returns ids + owner for every matching review within one page", async () => { + // Backs the "select all matching" bulk action: needs only id + + // user_id, not the full review payload, for the entire filtered set. + const result = await admin.rpc("get_tabular_review_ids_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "in-project", + p_search_term: "needle", + p_limit: 1000, + p_offset: 0, + }); + + expect(result.error).toBeNull(); + const rows = (result.data ?? []) as { id: string; user_id: string }[]; + expect(rows).toHaveLength(projectReviewIds.length); + expect(new Set(rows.map((row) => row.id))).toEqual( + new Set(projectReviewIds), + ); + expect(rows.every((row) => row.user_id === ownerId)).toBe(true); + }); + + it("paginates the ids RPC deterministically without duplicates or gaps", async () => { + // Proves the pagination contract the /tabular-review/ids route relies + // on to page past PostgREST's own row cap: consecutive small pages + // must together cover the full filtered set with no overlap. + const pageSize = 10; + const collected: string[] = []; + for (let offset = 0; offset < projectReviewIds.length; offset += pageSize) { + const page = await admin.rpc("get_tabular_review_ids_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + p_scope: "in-project", + p_search_term: "needle", + p_limit: pageSize, + p_offset: offset, + }); + expect(page.error).toBeNull(); + collected.push(...(page.data ?? []).map((row) => row.id as string)); + } + + expect(new Set(collected).size).toBe(projectReviewIds.length); + expect([...collected].sort()).toEqual([...projectReviewIds].sort()); + }); + + it("keeps the legacy three-argument RPC callable", async () => { + const result = await admin.rpc("get_tabular_reviews_overview", { + p_user_id: ownerId, + p_user_email: ownerEmail, + p_project_id: null, + }); + + expect(result.error).toBeNull(); + const returnedIds = new Set( + (result.data ?? []).map((row) => row.id as string), + ); + for (const id of [...projectReviewIds, ...standaloneReviewIds]) + expect(returnedIds.has(id)).toBe(true); + }); +}); diff --git a/backend/src/lib/__tests__/pagination.test.ts b/backend/src/lib/__tests__/pagination.test.ts new file mode 100644 index 000000000..7b63e6c04 --- /dev/null +++ b/backend/src/lib/__tests__/pagination.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { parsePaginationQuery } from "../pagination"; + +describe("parsePaginationQuery", () => { + it("parses valid limit and offset values", () => { + expect(parsePaginationQuery({ limit: "15", offset: "3" })).toEqual({ + limit: 15, + offset: 3, + }); + }); + + it("clamps invalid values to sensible defaults", () => { + expect(parsePaginationQuery({ limit: "500", offset: "-2" })).toEqual({ + limit: 100, + offset: 0, + }); + }); + + it("uses defaults when values are missing", () => { + expect(parsePaginationQuery({})).toEqual({ limit: 20, offset: 0 }); + }); +}); diff --git a/backend/src/lib/__tests__/search.test.ts b/backend/src/lib/__tests__/search.test.ts new file mode 100644 index 000000000..7a6da9ff8 --- /dev/null +++ b/backend/src/lib/__tests__/search.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSearchTerm } from "../search"; + +describe("normalizeSearchTerm", () => { + it("trims whitespace and preserves meaningful search text", () => { + expect(normalizeSearchTerm(" merger agreement ")).toBe("merger agreement"); + }); + + it("returns null for empty or missing values", () => { + expect(normalizeSearchTerm(" ")).toBeNull(); + expect(normalizeSearchTerm(undefined)).toBeNull(); + }); +}); diff --git a/backend/src/lib/__tests__/sort.test.ts b/backend/src/lib/__tests__/sort.test.ts new file mode 100644 index 000000000..a3eff3a87 --- /dev/null +++ b/backend/src/lib/__tests__/sort.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { parseTabularReviewSort } from "../sort"; + +describe("parseTabularReviewSort", () => { + it("accepts a supported sort key and direction", () => { + expect(parseTabularReviewSort({ key: "documents", direction: "asc" })).toEqual({ + key: "documents", + direction: "asc", + }); + }); + + it("falls back to created desc for unsupported values", () => { + expect(parseTabularReviewSort({ key: "unknown", direction: "sideways" })).toEqual({ + key: "created", + direction: "desc", + }); + }); +}); diff --git a/backend/src/lib/__tests__/tabularReviewsOverview.test.ts b/backend/src/lib/__tests__/tabularReviewsOverview.test.ts new file mode 100644 index 000000000..c1583de74 --- /dev/null +++ b/backend/src/lib/__tests__/tabularReviewsOverview.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + buildTabularReviewIdsOverviewRpcArgs, + buildTabularReviewsOverviewRpcArgs, + parseTabularReviewScope, +} from "../tabularReviewsOverview"; + +describe("buildTabularReviewsOverviewRpcArgs", () => { + it("builds the full RPC payload when the paginated signature is requested", () => { + expect( + buildTabularReviewsOverviewRpcArgs({ + userId: "user-1", + userEmail: "user@example.com", + projectIdFilter: "project-1", + scope: "in-project", + pagination: { limit: 25, offset: 10 }, + searchTerm: "merger", + sort: { key: "name", direction: "asc" }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: "user@example.com", + p_project_id: "project-1", + p_scope: "in-project", + p_limit: 25, + p_offset: 10, + p_search_term: "merger", + p_sort_key: "name", + p_sort_direction: "asc", + }); + }); + + it("uses default pagination and sort values when omitted", () => { + expect( + buildTabularReviewsOverviewRpcArgs({ + userId: "user-1", + userEmail: undefined, + projectIdFilter: null, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: null, + p_project_id: null, + p_scope: "all", + p_limit: 20, + p_offset: 0, + p_search_term: null, + p_sort_key: "created", + p_sort_direction: "desc", + }); + }); +}); + +describe("buildTabularReviewIdsOverviewRpcArgs", () => { + it("builds the ids-only RPC payload with no sort but with pagination", () => { + expect( + buildTabularReviewIdsOverviewRpcArgs({ + userId: "user-1", + userEmail: "user@example.com", + projectIdFilter: "project-1", + scope: "in-project", + searchTerm: "merger", + pagination: { limit: 1000, offset: 2000 }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: "user@example.com", + p_project_id: "project-1", + p_scope: "in-project", + p_search_term: "merger", + p_limit: 1000, + p_offset: 2000, + }); + }); + + it("uses default scope and search values when omitted", () => { + expect( + buildTabularReviewIdsOverviewRpcArgs({ + userId: "user-1", + userEmail: undefined, + projectIdFilter: null, + pagination: { limit: 1000, offset: 0 }, + }), + ).toEqual({ + p_user_id: "user-1", + p_user_email: null, + p_project_id: null, + p_scope: "all", + p_search_term: null, + p_limit: 1000, + p_offset: 0, + }); + }); +}); + +describe("parseTabularReviewScope", () => { + it("accepts supported review scopes", () => { + expect(parseTabularReviewScope("in-project")).toBe("in-project"); + expect(parseTabularReviewScope("standalone")).toBe("standalone"); + }); + + it("falls back to all for missing or unsupported scopes", () => { + expect(parseTabularReviewScope(undefined)).toBe("all"); + expect(parseTabularReviewScope("shared")).toBe("all"); + }); +}); diff --git a/backend/src/lib/pagination.ts b/backend/src/lib/pagination.ts new file mode 100644 index 000000000..24c176b15 --- /dev/null +++ b/backend/src/lib/pagination.ts @@ -0,0 +1,21 @@ +export interface PaginationParams { + limit: number; + offset: number; +} + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export function parsePaginationQuery(value: Record): PaginationParams { + const requestedLimit = Number.parseInt(String(value.limit ?? ""), 10); + const requestedOffset = Number.parseInt(String(value.offset ?? ""), 10); + + const limit = Number.isFinite(requestedLimit) + ? Math.min(Math.max(requestedLimit, 1), MAX_LIMIT) + : DEFAULT_LIMIT; + const offset = Number.isFinite(requestedOffset) && requestedOffset > 0 + ? requestedOffset + : 0; + + return { limit, offset }; +} diff --git a/backend/src/lib/search.ts b/backend/src/lib/search.ts new file mode 100644 index 000000000..bb6238bc5 --- /dev/null +++ b/backend/src/lib/search.ts @@ -0,0 +1,5 @@ +export function normalizeSearchTerm(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} diff --git a/backend/src/lib/sort.ts b/backend/src/lib/sort.ts new file mode 100644 index 000000000..e112e3e2c --- /dev/null +++ b/backend/src/lib/sort.ts @@ -0,0 +1,23 @@ +export type TabularReviewSortKey = "name" | "columns" | "documents" | "created"; +export type TabularReviewSortDirection = "asc" | "desc"; + +export interface TabularReviewSort { + key: TabularReviewSortKey; + direction: TabularReviewSortDirection; +} + +const SUPPORTED_KEYS: TabularReviewSortKey[] = ["name", "columns", "documents", "created"]; + +export function parseTabularReviewSort(value: Record): TabularReviewSort { + const rawKey = typeof value.sort_key === "string" + ? value.sort_key + : typeof value.key === "string" + ? value.key + : null; + const key = rawKey && SUPPORTED_KEYS.includes(rawKey as TabularReviewSortKey) + ? (rawKey as TabularReviewSortKey) + : "created"; + const direction = value.sort_direction === "asc" || value.direction === "asc" ? "asc" : "desc"; + + return { key, direction }; +} diff --git a/backend/src/lib/tabularReviewsOverview.ts b/backend/src/lib/tabularReviewsOverview.ts new file mode 100644 index 000000000..ed71f7a4b --- /dev/null +++ b/backend/src/lib/tabularReviewsOverview.ts @@ -0,0 +1,74 @@ +export type TabularReviewScope = "all" | "in-project" | "standalone"; + +export function parseTabularReviewScope(value: unknown): TabularReviewScope { + if (value === "in-project" || value === "standalone") return value; + return "all"; +} + +export interface TabularReviewsOverviewRpcArgs { + p_user_id: string; + p_user_email: string | null; + p_project_id: string | null; + p_scope: TabularReviewScope; + p_limit: number; + p_offset: number; + p_search_term: string | null; + p_sort_key: string; + p_sort_direction: string; +} + +export function buildTabularReviewsOverviewRpcArgs(params: { + userId: string; + userEmail: string | undefined; + projectIdFilter: string | null; + scope?: TabularReviewScope; + pagination?: { limit: number; offset: number }; + searchTerm?: string | null; + sort?: { key: string; direction: string }; +}): TabularReviewsOverviewRpcArgs { + return { + p_user_id: params.userId, + p_user_email: params.userEmail ?? null, + p_project_id: params.projectIdFilter, + p_scope: params.scope ?? "all", + p_limit: params.pagination?.limit ?? 20, + p_offset: params.pagination?.offset ?? 0, + p_search_term: params.searchTerm ?? null, + p_sort_key: params.sort?.key ?? "created", + p_sort_direction: params.sort?.direction ?? "desc", + }; +} + +export interface TabularReviewIdsOverviewRpcArgs { + p_user_id: string; + p_user_email: string | null; + p_project_id: string | null; + p_scope: TabularReviewScope; + p_search_term: string | null; + p_limit: number; + p_offset: number; +} + +// Lightweight sibling of buildTabularReviewsOverviewRpcArgs for "select all +// matching" actions: no sort (order doesn't matter for a bulk id list), but +// still paginated — PostgREST enforces its own row cap on every RPC +// response, so a caller that skips pagination here will silently get a +// truncated id list back with no error. +export function buildTabularReviewIdsOverviewRpcArgs(params: { + userId: string; + userEmail: string | undefined; + projectIdFilter: string | null; + scope?: TabularReviewScope; + searchTerm?: string | null; + pagination: { limit: number; offset: number }; +}): TabularReviewIdsOverviewRpcArgs { + return { + p_user_id: params.userId, + p_user_email: params.userEmail ?? null, + p_project_id: params.projectIdFilter, + p_scope: params.scope ?? "all", + p_search_term: params.searchTerm ?? null, + p_limit: params.pagination.limit, + p_offset: params.pagination.offset, + }; +} diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 4c5c5c264..5f819fedf 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -42,6 +42,14 @@ import { findMissingUserEmails, loadProfileUsersByEmail, } from "../lib/userLookup"; +import { parsePaginationQuery } from "../lib/pagination"; +import { normalizeSearchTerm } from "../lib/search"; +import { parseTabularReviewSort } from "../lib/sort"; +import { + buildTabularReviewIdsOverviewRpcArgs, + buildTabularReviewsOverviewRpcArgs, + parseTabularReviewScope, +} from "../lib/tabularReviewsOverview"; function formatPromptSuffix(format?: string, tags?: string[]): string { switch (format) { @@ -96,17 +104,82 @@ tabularRouter.get("/", requireAuth, async (req, res) => { typeof req.query.project_id === "string" && req.query.project_id ? (req.query.project_id as string) : null; + const pagination = parsePaginationQuery(req.query as Record); + const searchTerm = normalizeSearchTerm(req.query.search); + const sort = parseTabularReviewSort(req.query as Record); + const scope = parseTabularReviewScope(req.query.scope); - const { data, error } = await db.rpc("get_tabular_reviews_overview", { - p_user_id: userId, - p_user_email: userEmail ?? null, - p_project_id: projectIdFilter, + const rpcArgs = buildTabularReviewsOverviewRpcArgs({ + userId, + userEmail, + projectIdFilter, + scope, + pagination, + searchTerm, + sort, }); + + const { data, error } = await db.rpc("get_tabular_reviews_overview", rpcArgs); if (error) return void res.status(500).json({ detail: error.message }); res.json(data ?? []); }); +// GET /tabular-review/ids (must come before /:reviewId routes) +// Lightweight id + owner list for every review matching the current +// filters — backs "select all matching" bulk actions so the client doesn't +// have to page through full review payloads just to collect checkboxes. +// +// PostgREST enforces its own row cap on every RPC response (db-max-rows), +// independent of anything this route asks for, and truncates silently +// (206 + a shorter array, no error) rather than failing. So this pages +// through the RPC itself — server-side, same-datacenter round trips — until +// a page comes back empty, rather than trusting one call to return +// everything. +const TABULAR_REVIEW_IDS_PAGE_SIZE = 1000; +const TABULAR_REVIEW_IDS_MAX_PAGES = 200; // guards a runaway loop, not a product limit + +tabularRouter.get("/ids", requireAuth, async (req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + + const projectIdFilter = + typeof req.query.project_id === "string" && req.query.project_id + ? (req.query.project_id as string) + : null; + const searchTerm = normalizeSearchTerm(req.query.search); + const scope = parseTabularReviewScope(req.query.scope); + + const ids: { id: string; user_id: string }[] = []; + let offset = 0; + for (let page = 0; page < TABULAR_REVIEW_IDS_MAX_PAGES; page++) { + const rpcArgs = buildTabularReviewIdsOverviewRpcArgs({ + userId, + userEmail, + projectIdFilter, + scope, + searchTerm, + pagination: { limit: TABULAR_REVIEW_IDS_PAGE_SIZE, offset }, + }); + const { data, error } = await db.rpc( + "get_tabular_review_ids_overview", + rpcArgs, + ); + if (error) return void res.status(500).json({ detail: error.message }); + + const rows = (data ?? []) as { id: string; user_id: string }[]; + if (rows.length === 0) break; + ids.push(...rows); + // Advance by what actually came back, not the requested page size — + // if PostgREST's cap is lower than TABULAR_REVIEW_IDS_PAGE_SIZE this + // still converges correctly instead of skipping rows. + offset += rows.length; + } + + res.json(ids); +}); + // POST /tabular-review tabularRouter.post("/", requireAuth, async (req, res) => { const userId = res.locals.userId as string; diff --git a/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx b/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx index 27eb504cd..d26ea92d2 100644 --- a/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/projects/[id]/tabular-reviews/page.tsx @@ -1,12 +1,9 @@ "use client"; -import { use, useCallback, useEffect, useMemo, useState } from "react"; +import { use, useCallback, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { ChevronDown } from "lucide-react"; -import { - deleteTabularReview, - updateTabularReview, -} from "@/app/lib/mikeApi"; +import { deleteTabularReview, updateTabularReview } from "@/app/lib/mikeApi"; import { ProjectReviewsTable } from "@/app/components/projects/ProjectReviewsTable"; import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal"; import { @@ -16,6 +13,14 @@ import { import type { TabularReview } from "@/app/components/shared/types"; import { useAuth } from "@/app/contexts/AuthContext"; import { TabPillButton } from "@/app/components/ui/tab-pill-button"; +import { WarningPopup } from "@/app/components/popups/WarningPopup"; +import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; +import { + type TabularReviewSortKey, + type TabularReviewSortDirection, + usePaginatedTabularReviews, +} from "@/app/hooks/usePaginatedTabularReviews"; +import { deleteTabularReviewsWithConcurrency } from "@/app/lib/deleteTabularReviewsWithConcurrency"; interface Props { params: Promise<{ id: string }>; @@ -36,9 +41,7 @@ function SelectedReviewActions({ return (
- onOpenChange(!open)} - > + onOpenChange(!open)}> Actions @@ -63,41 +66,49 @@ export default function ProjectTabularReviewsPage({ params }: Props) { const searchParams = useSearchParams(); const { user } = useAuth(); const previewEmptyStates = searchParams.get("emptyStates") === "1"; - const { - ensureProjectReviews, - project, - projectId, - projectReviews, - search, - setOwnerOnlyAction, - setProjectReviews, - } = workspace; - const [selectedReviewIds, setSelectedReviewIds] = useState([]); + const { project, projectId, search, setOwnerOnlyAction } = workspace; const [detailsReview, setDetailsReview] = useState( null, ); const [actionsOpen, setActionsOpen] = useState(false); + const [bulkDeleteNotice, setBulkDeleteNotice] = useState( + null, + ); + const [deletingReviewIds, setDeletingReviewIds] = useState>( + () => new Set(), + ); + const [sort, setSort] = useState<{ + key: TabularReviewSortKey; + direction: TabularReviewSortDirection; + } | null>(null); + const debouncedSearch = useDebouncedValue(search, 250); + const { + reviews, + setReviews, + loading, + loadingMore, + hasMore, + error: loadError, + loadMoreError, + loadMore, + retry, + selectedReviewIds, + setSelectedReviewIds, + selectAllMatching, + selectingAll, + getReviewOwnerId, + } = usePaginatedTabularReviews({ + projectId, + search: debouncedSearch, + selectionKey: search, + sort, + }); const docs = project?.documents ?? []; - const reviews = useMemo(() => projectReviews ?? [], [projectReviews]); - const visibleReviews = previewEmptyStates ? [] : reviews; - const loading = projectReviews === null && !previewEmptyStates; - - useEffect(() => { - void ensureProjectReviews(); - }, [ensureProjectReviews]); - - const q = search.toLowerCase(); - const filteredReviews = q - ? visibleReviews.filter((r) => - (r.title ?? "").toLowerCase().includes(q), - ) - : visibleReviews; - const allReviewsSelected = - filteredReviews.length > 0 && - filteredReviews.every((r) => selectedReviewIds.includes(r.id)); - const someReviewsSelected = - !allReviewsSelected && - filteredReviews.some((r) => selectedReviewIds.includes(r.id)); + const visibleReviews = useMemo( + () => (previewEmptyStates ? [] : reviews), + [previewEmptyStates, reviews], + ); + const effectiveLoading = loading && !previewEmptyStates; function handleOpenDetails(review: TabularReview) { if (user?.id && review.user_id !== user.id) { @@ -120,8 +131,8 @@ export default function ProjectTabularReviewsPage({ params }: Props) { title: values.title, project_id: projectId, }); - setProjectReviews((prev) => - (prev ?? []).map((review) => + setReviews((prev) => + prev.map((review) => review.id === updated.id ? { ...review, ...updated } : review, ), ); @@ -130,67 +141,116 @@ export default function ProjectTabularReviewsPage({ params }: Props) { ); } + function handleToggleAllReviews() { + const allSelected = + visibleReviews.length > 0 && + visibleReviews.every((review) => + selectedReviewIds.includes(review.id), + ); + if (allSelected) setSelectedReviewIds([]); + else void selectAllMatching(); + } + async function handleDeleteReviewRow(review: TabularReview) { if (user?.id && review.user_id !== user.id) { setOwnerOnlyAction("delete this tabular review"); return; } - await deleteTabularReview(review.id); - setProjectReviews((prev) => - (prev ?? []).filter((r) => r.id !== review.id), - ); + setDeletingReviewIds((current) => new Set(current).add(review.id)); + try { + await deleteTabularReview(review.id); + setReviews((prev) => prev.filter((r) => r.id !== review.id)); + } finally { + setDeletingReviewIds((current) => { + const next = new Set(current); + next.delete(review.id); + return next; + }); + } } const handleDeleteSelectedReviews = useCallback(async () => { const ids = [...selectedReviewIds]; setActionsOpen(false); + setBulkDeleteNotice(null); const owned = ids.filter((id) => { - const review = reviews.find((r) => r.id === id); - return !review || review.user_id === user?.id; + const ownerId = getReviewOwnerId(id); + return !!ownerId && ownerId === user?.id; }); const blocked = ids.length - owned.length; setSelectedReviewIds([]); - await Promise.all( - owned.map((id) => deleteTabularReview(id).catch(() => {})), - ); - setProjectReviews((prev) => - (prev ?? []).filter((review) => !owned.includes(review.id)), - ); - if (blocked > 0) { - setOwnerOnlyAction( - `delete ${blocked} of the selected reviews - only the review creator can delete a review`, + setDeletingReviewIds((current) => { + const next = new Set(current); + for (const id of owned) next.add(id); + return next; + }); + const { deletedIds, failedIds } = + await deleteTabularReviewsWithConcurrency( + owned, + deleteTabularReview, ); - } + setDeletingReviewIds((current) => { + const next = new Set(current); + for (const id of owned) next.delete(id); + return next; + }); + setSelectedReviewIds(failedIds); + setReviews((prev) => + prev.filter((review) => !deletedIds.includes(review.id)), + ); + const notices = [ + blocked > 0 + ? `${blocked} selected review${blocked === 1 ? " was" : "s were"} skipped because only the review creator can delete them.` + : null, + failedIds.length > 0 + ? `${failedIds.length} review${failedIds.length === 1 ? " was" : "s were"} not deleted because the request failed. ${failedIds.length === 1 ? "It remains" : "They remain"} selected so you can try again.` + : null, + ].filter((notice): notice is string => notice !== null); + if (notices.length > 0) setBulkDeleteNotice(notices.join(" ")); }, [ - reviews, + getReviewOwnerId, selectedReviewIds, - setOwnerOnlyAction, - setProjectReviews, + setReviews, + setSelectedReviewIds, user?.id, ]); return ( <> 0 ? ( - void handleDeleteSelectedReviews()} - /> - ) : undefined} + actions={ + selectedReviewIds.length > 0 ? ( + void handleDeleteSelectedReviews()} + /> + ) : undefined + } /> 0} + sort={sort} + onSortChange={(key, direction) => { + setSelectedReviewIds([]); + setSort(direction ? { key, direction } : null); + }} + onLoadMore={() => void loadMore()} + onRetry={retry} onCreateReview={workspace.openNewReview} onOpenReview={(reviewId) => router.push( @@ -214,6 +274,12 @@ export default function ProjectTabularReviewsPage({ params }: Props) { onClose={() => setDetailsReview(null)} onSave={handleDetailsSave} /> + setBulkDeleteNotice(null)} + /> ); } diff --git a/frontend/src/app/(pages)/tabular-reviews/page.tsx b/frontend/src/app/(pages)/tabular-reviews/page.tsx index 99e88c6ed..550bbc995 100644 --- a/frontend/src/app/(pages)/tabular-reviews/page.tsx +++ b/frontend/src/app/(pages)/tabular-reviews/page.tsx @@ -1,15 +1,15 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; +import { useDebouncedValue } from "@/app/hooks/useDebouncedValue"; import { useRouter, useSearchParams } from "next/navigation"; -import { ChevronDown, Plus } from "lucide-react"; +import { ChevronDown, Loader2, Plus } from "lucide-react"; import { RowActionMenuItems, RowActions, } from "@/app/components/shared/RowActions"; import { deleteTabularReview, - listTabularReviews, createTabularReview, listProjects, updateTabularReview, @@ -19,6 +19,7 @@ import { TableToolbar } from "@/app/components/shared/TableToolbar"; import { NewTRModal } from "@/app/components/tabular/NewTRModal"; import { TabularReviewDetailsModal } from "@/app/components/tabular/TabularReviewDetailsModal"; import { OwnerOnlyPopup } from "@/app/components/popups/OwnerOnlyPopup"; +import { WarningPopup } from "@/app/components/popups/WarningPopup"; import { useAuth } from "@/app/contexts/AuthContext"; import { PageHeader } from "@/app/components/shared/PageHeader"; import { @@ -42,8 +43,13 @@ import { PillButton } from "@/app/components/ui/pill-button"; import { TabPillButton } from "@/app/components/ui/tab-pill-button"; import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons"; import { LiquidDropdownSurface } from "@/app/components/ui/liquid-dropdown"; +import { + type TabularReviewScope, + usePaginatedTabularReviews, +} from "@/app/hooks/usePaginatedTabularReviews"; +import { deleteTabularReviewsWithConcurrency } from "@/app/lib/deleteTabularReviewsWithConcurrency"; -type ReviewScope = "all" | "in-project" | "standalone"; +type ReviewScope = TabularReviewScope; type ReviewSortKey = "name" | "columns" | "documents" | "created"; const REVIEW_SCOPES: { id: ReviewScope; label: string }[] = [ @@ -55,7 +61,6 @@ const SORT_OPTIONS: TableFilterOption[] = [ { value: "asc", label: "Ascending" }, { value: "desc", label: "Descending" }, ]; - function formatDate(iso: string) { return new Date(iso).toLocaleDateString(undefined, { day: "numeric", @@ -65,9 +70,7 @@ function formatDate(iso: string) { } export default function TabularReviewsPage() { - const [reviews, setReviews] = useState([]); const [projects, setProjects] = useState([]); - const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [newTROpen, setNewTROpen] = useState(false); const [detailsReview, setDetailsReview] = useState( @@ -80,9 +83,37 @@ export default function TabularReviewsPage() { direction: TableSortDirection; } | null>(null); const [search, setSearch] = useState(""); - const [selectedIds, setSelectedIds] = useState([]); + const debouncedSearch = useDebouncedValue(search, 250); + const { + reviews, + setReviews, + loading, + loadingMore, + hasMore, + error: loadError, + loadMoreError, + loadMore, + retry, + selectedReviewIds: selectedIds, + setSelectedReviewIds: setSelectedIds, + selectAllMatching, + selectingAll, + getReviewOwnerId, + } = usePaginatedTabularReviews({ + projectId: projectFilter ?? undefined, + search: debouncedSearch, + selectionKey: search, + scope: activeScope, + sort, + }); const [actionsOpen, setActionsOpen] = useState(false); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); + const [bulkDeleteNotice, setBulkDeleteNotice] = useState( + null, + ); + const [deletingReviewIds, setDeletingReviewIds] = useState>( + () => new Set(), + ); const actionsRef = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); @@ -95,20 +126,30 @@ export default function TabularReviewsPage() { ); useEffect(() => { - Promise.all([ - listTabularReviews().catch(() => []), - listProjects().catch(() => []), - ]) - .then(([r, p]) => { - setReviews(r); - setProjects(p); + let cancelled = false; + void listProjects() + .then((loadedProjects) => { + if (!cancelled) setProjects(loadedProjects); }) - .finally(() => setLoading(false)); + .catch(() => { + if (!cancelled) setProjects([]); + }); + return () => { + cancelled = true; + }; }, []); - useEffect(() => { - setSelectedIds([]); - }, [activeScope, projectFilter]); + function handleLoadMore() { + void loadMore(); + } + + function handleScroll(event: React.UIEvent) { + if (loading || loadingMore || !hasMore) return; + const el = event.currentTarget; + const distanceToBottom = + el.scrollHeight - el.scrollTop - el.clientHeight; + if (distanceToBottom < 200) void loadMore(); + } useEffect(() => { function handleClick(e: MouseEvent) { @@ -127,52 +168,7 @@ export default function TabularReviewsPage() { () => new Map(projects.map((project) => [project.id, project.name])), [projects], ); - const q = search.toLowerCase(); - const filtered = useMemo(() => { - const rows = visibleReviews - .filter((r) => { - if (activeScope === "in-project") return !!r.project_id; - if (activeScope === "standalone") return !r.project_id; - return true; - }) - .filter((r) => !projectFilter || r.project_id === projectFilter) - .filter((r) => !q || (r.title ?? "").toLowerCase().includes(q)); - - if (!sort) return rows; - - return [...rows].sort((a, b) => { - const multiplier = sort.direction === "asc" ? 1 : -1; - - if (sort.key === "columns") { - return ( - ((a.columns_config?.length ?? 0) - - (b.columns_config?.length ?? 0)) * - multiplier - ); - } - - if (sort.key === "documents") { - return ( - ((a.document_count ?? 0) - (b.document_count ?? 0)) * - multiplier - ); - } - - if (sort.key === "created") { - return ( - (new Date(a.created_at).getTime() - - new Date(b.created_at).getTime()) * - multiplier - ); - } - - return ( - (a.title ?? "Untitled Review").localeCompare( - b.title ?? "Untitled Review", - ) * multiplier - ); - }); - }, [activeScope, projectFilter, q, sort, visibleReviews]); + const filtered = visibleReviews; const allSelected = filtered.length > 0 && @@ -182,7 +178,7 @@ export default function TabularReviewsPage() { function toggleAll() { if (allSelected) setSelectedIds([]); - else setSelectedIds(filtered.map((r) => r.id)); + else void selectAllMatching(); } function toggleOne(id: string) { @@ -269,20 +265,60 @@ export default function TabularReviewsPage() { async function handleDeleteSelected() { const ids = [...selectedIds]; setActionsOpen(false); + setBulkDeleteNotice(null); const owned = ids.filter((id) => { - const r = reviews.find((rr) => rr.id === id); - return !r || !user?.id || r.user_id === user.id; + const ownerId = getReviewOwnerId(id); + return !!ownerId && (!user?.id || ownerId === user.id); }); const blocked = ids.length - owned.length; setSelectedIds([]); - await Promise.all( - owned.map((id) => deleteTabularReview(id).catch(() => {})), + setDeletingReviewIds((current) => { + const next = new Set(current); + for (const id of owned) next.add(id); + return next; + }); + const { deletedIds, failedIds } = + await deleteTabularReviewsWithConcurrency( + owned, + deleteTabularReview, + ); + setDeletingReviewIds((current) => { + const next = new Set(current); + for (const id of owned) next.delete(id); + return next; + }); + setSelectedIds(failedIds); + setReviews((prev) => + prev.filter((review) => !deletedIds.includes(review.id)), ); - setReviews((prev) => prev.filter((r) => !owned.includes(r.id))); - if (blocked > 0) { - setOwnerOnlyAction( - `delete ${blocked} of the selected reviews — only the review creator can delete a review`, + const notices = [ + blocked > 0 + ? `${blocked} selected review${blocked === 1 ? " was" : "s were"} skipped because only the review creator can delete them.` + : null, + failedIds.length > 0 + ? `${failedIds.length} review${failedIds.length === 1 ? " was" : "s were"} not deleted because the request failed. ${failedIds.length === 1 ? "It remains" : "They remain"} selected so you can try again.` + : null, + ].filter((notice): notice is string => notice !== null); + if (notices.length > 0) setBulkDeleteNotice(notices.join(" ")); + } + + async function handleDeleteReviewRow(review: TabularReview) { + if (user?.id && review.user_id !== user.id) { + setOwnerOnlyAction("delete this tabular review"); + return; + } + setDeletingReviewIds((current) => new Set(current).add(review.id)); + try { + await deleteTabularReview(review.id); + setReviews((prev) => + prev.filter((current) => current.id !== review.id), ); + } finally { + setDeletingReviewIds((current) => { + const next = new Set(current); + next.delete(review.id); + return next; + }); } } @@ -350,9 +386,7 @@ export default function TabularReviewsPage() { const toolbarActions = selectedIds.length > 0 ? (
- setActionsOpen((v) => !v)} - > + setActionsOpen((v) => !v)}> Actions @@ -406,6 +440,7 @@ export default function TabularReviewsPage() { {/* Table */} @@ -415,6 +450,10 @@ export default function TabularReviewsPage() { 0 + } ref={(el) => { if (el) el.indeterminate = someSelected; }} @@ -456,10 +495,7 @@ export default function TabularReviewsPage() { {effectiveLoading ? ( {[1, 2, 3].map((i) => ( - + ))} + ) : loadError ? ( + +

+ Unable to load reviews +

+

+ Check your connection and try again. +

+ + Try again + +
) : filtered.length === 0 ? ( - {activeScope === "all" && !projectFilter ? ( + {activeScope === "all" && + !projectFilter && + !debouncedSearch ? ( <>

@@ -518,51 +573,64 @@ export default function TabularReviewsPage() { const projectName = review.project_id ? projectNameById.get(review.project_id) : null; + const deleting = deletingReviewIds.has(review.id); return ( ( - { - requestReviewDetails(review); - }} - onDelete={async () => { - if ( - user?.id && - review.user_id !== user.id - ) { - setOwnerOnlyAction( - "delete this tabular review", - ); - return; - } - await deleteTabularReview( - review.id, - ); - setReviews((prev) => - prev.filter( - (r) => - r.id !== review.id, - ), - ); - }} - /> - )} - onClick={() => { - router.push( - review.project_id - ? `/projects/${review.project_id}/tabular-reviews/${review.id}` - : `/tabular-reviews/${review.id}`, - ); - }} + interactive={!deleting} + selected={ + !deleting && + selectedIds.includes(review.id) + } + rightClickDropdown={ + deleting + ? undefined + : (close, menuProps) => ( + { + requestReviewDetails( + review, + ); + }} + onDelete={() => + handleDeleteReviewRow( + review, + ) + } + /> + ) + } + onClick={ + deleting + ? undefined + : () => { + router.push( + review.project_id + ? `/projects/${review.project_id}/tabular-reviews/${review.id}` + : `/tabular-reviews/${review.id}`, + ); + } + } + className={ + deleting + ? "pointer-events-none opacity-50" + : undefined + } > + ) : undefined + } onSelectionChange={() => toggleOne(review.id) } @@ -602,33 +670,34 @@ export default function TabularReviewsPage() { onEditDetails={() => { requestReviewDetails(review); }} - onDelete={async () => { - if ( - user?.id && - review.user_id !== user.id - ) { - setOwnerOnlyAction( - "delete this tabular review", - ); - return; - } - await deleteTabularReview( - review.id, - ); - setReviews((prev) => - prev.filter( - (r) => - r.id !== review.id, - ), - ); - }} - /> + onDelete={() => + handleDeleteReviewRow(review) + } + />

); })} )} + {!effectiveLoading && hasMore && filtered.length > 0 && ( +
+ +
+ )} setOwnerOnlyAction(null)} /> + setBulkDeleteNotice(null)} + />
); } diff --git a/frontend/src/app/components/projects/ProjectReviewsTable.tsx b/frontend/src/app/components/projects/ProjectReviewsTable.tsx index 3f08db5ef..6e80b9471 100644 --- a/frontend/src/app/components/projects/ProjectReviewsTable.tsx +++ b/frontend/src/app/components/projects/ProjectReviewsTable.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMemo, useState, type Dispatch, type SetStateAction } from "react"; -import { Plus } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import { Loader2, Plus } from "lucide-react"; import { RowActionMenuItems, RowActions, @@ -27,8 +27,10 @@ import { PillButton } from "@/app/components/ui/pill-button"; import { TabularReviewSkeuoIcon } from "@/app/components/shared/AppSidebarSkeuoIcons"; import type { Document, TabularReview } from "@/app/components/shared/types"; import { formatDate } from "./ProjectPageParts"; - -type ProjectReviewSortKey = "name" | "columns" | "documents" | "created"; +import type { + TabularReviewSortDirection, + TabularReviewSortKey, +} from "@/app/hooks/usePaginatedTabularReviews"; const SORT_OPTIONS: TableFilterOption[] = [ { value: "asc", label: "Ascending" }, @@ -38,7 +40,6 @@ const SORT_OPTIONS: TableFilterOption[] = [ export function ProjectReviewsTable({ docs, reviews, - filteredReviews, selectedReviewIds, creatingReview, currentUserId, @@ -48,14 +49,23 @@ export function ProjectReviewsTable({ onDeleteReview, onOwnerOnlyAction, setSelectedReviewIds, + onToggleAll, + selectingAll = false, + deletingReviewIds, + hasActiveSearch, + sort, + onSortChange, + hasMore, + loadingMore, + error, + loadMoreError, + onLoadMore, + onRetry, loading = false, }: { docs: Document[]; reviews: TabularReview[]; - filteredReviews: TabularReview[]; selectedReviewIds: string[]; - allReviewsSelected: boolean; - someReviewsSelected: boolean; creatingReview: boolean; currentUserId?: string | null; onCreateReview: () => void; @@ -64,61 +74,39 @@ export function ProjectReviewsTable({ onDeleteReview: (review: TabularReview) => Promise | void; onOwnerOnlyAction: (action: string) => void; setSelectedReviewIds: Dispatch>; + onToggleAll: () => void; + selectingAll?: boolean; + deletingReviewIds: ReadonlySet; + hasActiveSearch: boolean; + sort: { + key: TabularReviewSortKey; + direction: TabularReviewSortDirection; + } | null; + onSortChange: ( + key: TabularReviewSortKey, + direction: TableSortDirection | null, + ) => void; + hasMore: boolean; + loadingMore: boolean; + error: Error | null; + loadMoreError: Error | null; + onLoadMore: () => void; + onRetry: () => void; loading?: boolean; }) { - const [sort, setSort] = useState<{ - key: ProjectReviewSortKey; - direction: TableSortDirection; - } | null>(null); - function clearSelection() { setSelectedReviewIds([]); } function handleSortChange( - key: ProjectReviewSortKey, + key: TabularReviewSortKey, direction: TableSortDirection | null, ) { - setSort(direction ? { key, direction } : null); + onSortChange(key, direction); clearSelection(); } - const visibleReviews = useMemo(() => { - if (!sort) return filteredReviews; - - return [...filteredReviews].sort((a, b) => { - const multiplier = sort.direction === "asc" ? 1 : -1; - - if (sort.key === "columns") { - return ( - ((a.columns_config?.length ?? 0) - - (b.columns_config?.length ?? 0)) * - multiplier - ); - } - - if (sort.key === "documents") { - return ( - ((a.document_count ?? 0) - (b.document_count ?? 0)) * - multiplier - ); - } - - if (sort.key === "created") { - return ( - (new Date(a.created_at).getTime() - - new Date(b.created_at).getTime()) * - multiplier - ); - } - - return ( - (a.title ?? "Untitled Review").localeCompare( - b.title ?? "Untitled Review", - ) * multiplier - ); - }); - }, [filteredReviews, sort]); + const visibleReviews = reviews; const allVisibleReviewsSelected = visibleReviews.length > 0 && @@ -177,6 +165,15 @@ export function ProjectReviewsTable({ return ( { + if (loading || loadingMore || !hasMore) return; + const element = event.currentTarget; + const distanceToBottom = + element.scrollHeight - + element.scrollTop - + element.clientHeight; + if (distanceToBottom < 200) onLoadMore(); + }} header={ @@ -186,19 +183,15 @@ export function ProjectReviewsTable({ 0 + } ref={(el) => { if (el) el.indeterminate = someVisibleReviewsSelected; }} - onChange={() => { - if (allVisibleReviewsSelected) - setSelectedReviewIds([]); - else - setSelectedReviewIds( - visibleReviews.map((r) => r.id), - ); - }} + onChange={onToggleAll} className={TABLE_CHECKBOX_CLASS} /> )} @@ -229,104 +222,178 @@ export function ProjectReviewsTable({ > {loading ? ( - ) : reviews.length === 0 ? ( + ) : error ? ( - -

- Tabular Reviews +

+ Unable to load reviews

-

- Extract data from project documents into tables using AI. +

+ Check your connection and try again.

- - Create + Try again
+ ) : reviews.length === 0 ? ( + + {hasActiveSearch ? ( +

+ No reviews found +

+ ) : ( + <> + +

+ Tabular Reviews +

+

+ Extract data from project documents into tables + using AI. +

+ + + Create + + + )} +
) : ( - {visibleReviews.map((review) => ( - ( - { - if ( - currentUserId && - review.user_id !== currentUserId - ) { - onOwnerOnlyAction( - "edit tabular review details", - ); - return; - } - onOpenDetails(review); - }} - onDelete={() => onDeleteReview(review)} - /> - )} - onClick={() => onOpenReview(review.id)} - className="pr-8 md:pr-8" - > - - setSelectedReviewIds((prev) => - prev.includes(review.id) - ? prev.filter( - (x) => x !== review.id, - ) - : [...prev, review.id], - ) + {visibleReviews.map((review) => { + const deleting = deletingReviewIds.has(review.id); + return ( + ( + { + if ( + currentUserId && + review.user_id !== + currentUserId + ) { + onOwnerOnlyAction( + "edit tabular review details", + ); + return; + } + onOpenDetails(review); + }} + onDelete={() => + onDeleteReview(review) + } + /> + ) + } + onClick={ + deleting + ? undefined + : () => onOpenReview(review.id) + } + className={ + deleting + ? "pointer-events-none pr-8 opacity-50 md:pr-8" + : "pr-8 md:pr-8" } - label={review.title ?? "Untitled Review"} - /> - - {review.columns_config?.length ?? 0} - - - {review.document_count ?? 0} - - - {review.created_at ? ( - formatDate(review.created_at) - ) : ( - - )} - -
e.stopPropagation()} > - { - if ( - currentUserId && - review.user_id !== currentUserId - ) { - onOwnerOnlyAction( - "edit tabular review details", - ); - return; - } - onOpenDetails(review); - }} - onDelete={() => onDeleteReview(review)} + + ) : undefined + } + onSelectionChange={() => + setSelectedReviewIds((prev) => + prev.includes(review.id) + ? prev.filter( + (x) => x !== review.id, + ) + : [...prev, review.id], + ) + } + label={review.title ?? "Untitled Review"} /> -
-
- ))} + + {review.columns_config?.length ?? 0} + + + {review.document_count ?? 0} + + + {review.created_at ? ( + formatDate(review.created_at) + ) : ( + + )} + +
e.stopPropagation()} + > + { + if ( + currentUserId && + review.user_id !== currentUserId + ) { + onOwnerOnlyAction( + "edit tabular review details", + ); + return; + } + onOpenDetails(review); + }} + onDelete={() => onDeleteReview(review)} + /> +
+
+ ); + })}
)} + {!loading && hasMore && reviews.length > 0 && ( +
+ +
+ )}
); } @@ -337,11 +404,7 @@ function ProjectReviewsLoadingRows() { return ( {[1, 2, 3, 4, 5].map((i) => ( - +
diff --git a/frontend/src/app/components/projects/ProjectWorkspace.tsx b/frontend/src/app/components/projects/ProjectWorkspace.tsx index 743f6a1e7..094a7aeac 100644 --- a/frontend/src/app/components/projects/ProjectWorkspace.tsx +++ b/frontend/src/app/components/projects/ProjectWorkspace.tsx @@ -18,7 +18,6 @@ import { getProject, getProjectPeople, listProjectChats, - listTabularReviews, updateProject, } from "@/app/lib/mikeApi"; import type { @@ -26,7 +25,6 @@ import type { ColumnConfig, Folder as ProjectFolder, Project, - TabularReview, } from "@/app/components/shared/types"; import { TableToolbar } from "@/app/components/shared/TableToolbar"; import { NewTRModal } from "@/app/components/tabular/NewTRModal"; @@ -56,12 +54,6 @@ type ProjectWorkspaceValue = { setProjectChats: React.Dispatch>; projectChatsLoading: boolean; ensureProjectChats: () => Promise; - projectReviews: TabularReview[] | null; - setProjectReviews: React.Dispatch< - React.SetStateAction - >; - projectReviewsLoading: boolean; - ensureProjectReviews: () => Promise; prefetchProjectSections: () => void; creatingChat: boolean; creatingReview: boolean; @@ -116,11 +108,7 @@ export function ProjectWorkspaceProvider({ Record >({ documents: "", assistant: "", reviews: "" }); const [projectChats, setProjectChats] = useState(null); - const [projectReviews, setProjectReviews] = useState< - TabularReview[] | null - >(null); const [projectChatsLoading, setProjectChatsLoading] = useState(false); - const [projectReviewsLoading, setProjectReviewsLoading] = useState(false); const [peopleModalOpen, setPeopleModalOpen] = useState(false); const [projectDetailsOpen, setProjectDetailsOpen] = useState(false); const [ownerOnlyAction, setOwnerOnlyAction] = useState(null); @@ -143,17 +131,11 @@ export function ProjectWorkspaceProvider({ const { profile } = useUserProfile(); const { saveChat } = useChatHistoryContext(); const projectChatsPromiseRef = useRef | null>(null); - const projectReviewsPromiseRef = useRef | null>( - null, - ); useEffect(() => { setProjectChats(null); - setProjectReviews(null); setProjectChatsLoading(false); - setProjectReviewsLoading(false); projectChatsPromiseRef.current = null; - projectReviewsPromiseRef.current = null; }, [projectId]); const setAddDocumentsHeaderAction = useCallback( @@ -224,34 +206,9 @@ export function ProjectWorkspaceProvider({ return promise; }, [projectChats, projectId]); - const ensureProjectReviews = useCallback(() => { - if (projectReviews) return Promise.resolve(projectReviews); - if (projectReviewsPromiseRef.current) - return projectReviewsPromiseRef.current; - - setProjectReviewsLoading(true); - const promise = listTabularReviews(projectId) - .then((loaded) => { - setProjectReviews(loaded); - return loaded; - }) - .catch((error) => { - console.error("[project reviews] failed to load", error); - setProjectReviews([]); - return []; - }) - .finally(() => { - projectReviewsPromiseRef.current = null; - setProjectReviewsLoading(false); - }); - projectReviewsPromiseRef.current = promise; - return promise; - }, [projectId, projectReviews]); - const prefetchProjectSections = useCallback(() => { void ensureProjectChats(); - void ensureProjectReviews(); - }, [ensureProjectChats, ensureProjectReviews]); + }, [ensureProjectChats]); const createChat = useCallback(async () => { setCreatingChat(true); @@ -305,7 +262,6 @@ export function ProjectWorkspaceProvider({ columns_config: columnsConfig ?? [], project_id: projectId, }); - setProjectReviews((prev) => (prev ? [review, ...prev] : prev)); router.push(`/projects/${projectId}/tabular-reviews/${review.id}`); } finally { setCreatingReview(false); @@ -379,10 +335,6 @@ export function ProjectWorkspaceProvider({ setProjectChats, projectChatsLoading, ensureProjectChats, - projectReviews, - setProjectReviews, - projectReviewsLoading, - ensureProjectReviews, prefetchProjectSections, creatingChat, creatingReview, @@ -402,9 +354,6 @@ export function ProjectWorkspaceProvider({ projectChats, projectChatsLoading, ensureProjectChats, - projectReviews, - projectReviewsLoading, - ensureProjectReviews, prefetchProjectSections, creatingChat, creatingReview, diff --git a/frontend/src/app/components/shared/PageHeader.tsx b/frontend/src/app/components/shared/PageHeader.tsx index a7ca5f829..0efc37dcd 100644 --- a/frontend/src/app/components/shared/PageHeader.tsx +++ b/frontend/src/app/components/shared/PageHeader.tsx @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; -import { ChevronLeft, Loader2, Plus, Search } from "lucide-react"; +import { ChevronLeft, Loader2, Plus, Search, X } from "lucide-react"; import { usePageChrome } from "@/app/contexts/PageChromeContext"; import { cn } from "@/app/lib/utils"; import { @@ -292,21 +292,22 @@ function PageHeaderSearchActionControl({ const [open, setOpen] = useState(false); const ref = useRef(null); const placeholder = action.placeholder ?? "Search…"; + const hasValue = action.value.length > 0; + const expanded = open || hasValue; useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); - action.onChange(""); } } if (open) document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); - }, [open, action]); + }, [open]); return (
- {open ? ( + {expanded ? (
action.onChange(e.target.value)} + onFocus={() => setOpen(true)} className="flex-1 text-sm text-gray-700 placeholder:text-gray-400 outline-none bg-transparent" /> + {hasValue && ( + + )}
) : ( (null); return ( -
-
+
+
{header && (
{ document.removeEventListener("click", handleClick); document.removeEventListener( @@ -282,9 +295,7 @@ export function TableRow({ className={cn( "group flex h-10 min-w-max items-center pr-3 transition-colors", interactive && "cursor-pointer", - interactive && - !selected && - APP_SURFACE_HOVER_CLASS, + interactive && !selected && APP_SURFACE_HOVER_CLASS, selected && APP_SURFACE_ACTIVE_CLASS, className, )} @@ -353,6 +364,7 @@ export function TablePrimaryCell({ bgClassName, selected, onSelectionChange, + selectionIndicator, checkboxTitle, label, editing = false, @@ -365,6 +377,7 @@ export function TablePrimaryCell({ bgClassName?: string; selected: boolean; onSelectionChange: () => void; + selectionIndicator?: ReactNode; checkboxTitle?: string; label?: ReactNode; editing?: boolean; @@ -400,21 +413,21 @@ export function TablePrimaryCell({ return (
- e.stopPropagation()} - className={TABLE_CHECKBOX_CLASS} - title={checkboxTitle} - /> + {selectionIndicator ?? ( + e.stopPropagation()} + className={TABLE_CHECKBOX_CLASS} + title={checkboxTitle} + /> + )} {content}
diff --git a/frontend/src/app/hooks/useDebouncedValue.ts b/frontend/src/app/hooks/useDebouncedValue.ts new file mode 100644 index 000000000..8490946bd --- /dev/null +++ b/frontend/src/app/hooks/useDebouncedValue.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useDebouncedValue(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => window.clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/frontend/src/app/hooks/usePaginatedTabularReviews.test.ts b/frontend/src/app/hooks/usePaginatedTabularReviews.test.ts new file mode 100644 index 000000000..cff6156df --- /dev/null +++ b/frontend/src/app/hooks/usePaginatedTabularReviews.test.ts @@ -0,0 +1,226 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TabularReview } from "@/app/components/shared/types"; +import { listTabularReviewIds, listTabularReviews } from "@/app/lib/mikeApi"; +import { usePaginatedTabularReviews } from "./usePaginatedTabularReviews"; + +vi.mock("@/app/lib/mikeApi", () => ({ + listTabularReviews: vi.fn(), + listTabularReviewIds: vi.fn(), +})); + +const listTabularReviewsMock = vi.mocked(listTabularReviews); +const listTabularReviewIdsMock = vi.mocked(listTabularReviewIds); + +function review(id: string): TabularReview { + return { + id, + project_id: null, + user_id: "user-1", + title: `Review ${id}`, + columns_config: [], + document_ids: [], + workflow_id: null, + shared_with: [], + created_at: "2026-07-27T00:00:00.000Z", + updated_at: "2026-07-27T00:00:00.000Z", + }; +} + +describe("usePaginatedTabularReviews", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("scopes selected IDs to the current search query", async () => { + listTabularReviewsMock.mockResolvedValue([review("one")]); + + const { result, rerender } = renderHook( + ({ search }) => + usePaginatedTabularReviews({ + search, + selectionKey: search, + }), + { initialProps: { search: "" } }, + ); + + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setSelectedReviewIds(["one"])); + expect(result.current.selectedReviewIds).toEqual(["one"]); + + rerender({ search: "another query" }); + expect(result.current.selectedReviewIds).toEqual([]); + }); + + it("blocks select all until the debounced search matches the typed search", async () => { + const firstPageRows = Array.from({ length: 21 }, (_, index) => + review(`row-${index}`), + ); + listTabularReviewsMock.mockResolvedValue(firstPageRows); + + const { result, rerender } = renderHook( + ({ search, selectionKey }) => + usePaginatedTabularReviews({ search, selectionKey }), + { + initialProps: { + search: "", + selectionKey: "", + }, + }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ search: "", selectionKey: "new search" }); + expect(result.current.selectingAll).toBe(true); + + await act(async () => { + await result.current.selectAllMatching(); + }); + expect(listTabularReviewIdsMock).not.toHaveBeenCalled(); + expect(result.current.selectedReviewIds).toEqual([]); + + const matchingIds = [{ id: "matching", user_id: "user-1" }]; + listTabularReviewIdsMock.mockResolvedValueOnce(matchingIds); + rerender({ search: "new search", selectionKey: "new search" }); + expect(result.current.selectingAll).toBe(false); + + await act(async () => { + await result.current.selectAllMatching(); + }); + expect(listTabularReviewIdsMock).toHaveBeenCalledWith(undefined, { + search: "new search", + scope: "all", + }); + expect(result.current.selectedReviewIds).toEqual(["matching"]); + }); + + it("clears a row selected during a debounced query transition", async () => { + listTabularReviewsMock.mockResolvedValue([review("old-row")]); + + const { result, rerender } = renderHook( + ({ search, selectionKey }) => + usePaginatedTabularReviews({ search, selectionKey }), + { + initialProps: { + search: "old", + selectionKey: "old", + }, + }, + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + + rerender({ search: "old", selectionKey: "new" }); + act(() => result.current.setSelectedReviewIds(["old-row"])); + expect(result.current.selectedReviewIds).toEqual(["old-row"]); + + rerender({ search: "new", selectionKey: "new" }); + expect(result.current.selectedReviewIds).toEqual([]); + }); + + it("aborts an obsolete request when the query changes", async () => { + let firstSignal: AbortSignal | undefined; + listTabularReviewsMock + .mockImplementationOnce((_projectId, options) => { + firstSignal = options?.signal; + return new Promise((_resolve, reject) => { + firstSignal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }); + }) + .mockResolvedValueOnce([review("new")]); + + const { result, rerender } = renderHook( + ({ search }) => usePaginatedTabularReviews({ search }), + { initialProps: { search: "old" } }, + ); + await waitFor(() => expect(firstSignal).toBeDefined()); + + rerender({ search: "new" }); + expect(firstSignal?.aborted).toBe(true); + await waitFor(() => + expect(result.current.reviews).toEqual([review("new")]), + ); + expect(result.current.error).toBeNull(); + }); + + it("exposes initial-load errors and retries the query", async () => { + listTabularReviewsMock + .mockRejectedValueOnce(new Error("network unavailable")) + .mockResolvedValueOnce([review("retry")]); + + const { result } = renderHook(() => usePaginatedTabularReviews({})); + + await waitFor(() => + expect(result.current.error?.message).toBe("network unavailable"), + ); + act(() => result.current.retry()); + + await waitFor(() => + expect(result.current.reviews).toEqual([review("retry")]), + ); + expect(result.current.error).toBeNull(); + }); + + it("selects every review matching the filters via a single lightweight ids request", async () => { + // First page load: 30 rows + 1 to signal more pages exist. + const firstPageRows = Array.from({ length: 31 }, (_, i) => + review(`row-${i}`), + ); + listTabularReviewsMock.mockResolvedValueOnce(firstPageRows); + + const { result } = renderHook(() => + usePaginatedTabularReviews({ scope: "in-project" }), + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.reviews).toHaveLength(30); + expect(result.current.hasMore).toBe(true); + + // selectAllMatching should ask for ids only (not full review rows) — + // this stands in for a filter match spanning far more than one page. + const allMatches = Array.from({ length: 150 }, (_, i) => ({ + id: `all-${i}`, + user_id: "user-1", + })); + listTabularReviewIdsMock.mockResolvedValueOnce(allMatches); + + await act(async () => { + await result.current.selectAllMatching(); + }); + + const expectedIds = allMatches.map((row) => row.id); + expect(result.current.selectedReviewIds).toEqual(expectedIds); + + // It's a single round trip for ids/owners, not a loop over full pages. + expect(listTabularReviewIdsMock).toHaveBeenCalledTimes(1); + expect(listTabularReviewIdsMock).toHaveBeenCalledWith(undefined, { + search: undefined, + scope: "in-project", + }); + // No extra calls to the full-row endpoint beyond the initial page load. + expect(listTabularReviewsMock).toHaveBeenCalledTimes(1); + + // Ids beyond the loaded page still resolve an owner for bulk actions + // (e.g. delete) that need to know who can delete each selection. + expect(result.current.getReviewOwnerId("all-149")).toBe("user-1"); + expect(result.current.getReviewOwnerId("row-0")).toBe("user-1"); + }); + + it("selects already-loaded reviews without a network request once everything is loaded", async () => { + listTabularReviewsMock.mockResolvedValueOnce([ + review("one"), + review("two"), + ]); + + const { result } = renderHook(() => usePaginatedTabularReviews({})); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hasMore).toBe(false); + + await act(async () => { + await result.current.selectAllMatching(); + }); + + expect(listTabularReviewIdsMock).not.toHaveBeenCalled(); + expect(result.current.selectedReviewIds).toEqual(["one", "two"]); + }); +}); diff --git a/frontend/src/app/hooks/usePaginatedTabularReviews.ts b/frontend/src/app/hooks/usePaginatedTabularReviews.ts new file mode 100644 index 000000000..434257fe7 --- /dev/null +++ b/frontend/src/app/hooks/usePaginatedTabularReviews.ts @@ -0,0 +1,278 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import type { TabularReview } from "@/app/components/shared/types"; +import { listTabularReviewIds, listTabularReviews } from "@/app/lib/mikeApi"; + +export type TabularReviewSortKey = "name" | "columns" | "documents" | "created"; +export type TabularReviewSortDirection = "asc" | "desc"; +export type TabularReviewScope = "all" | "in-project" | "standalone"; + +const PAGE_SIZE = 30; + +function pageRows(rows: TabularReview[]) { + return { + hasMore: rows.length > PAGE_SIZE, + rows: rows.slice(0, PAGE_SIZE), + }; +} + +function asError(value: unknown) { + return value instanceof Error + ? value + : new Error("Unable to load tabular reviews"); +} + +export function usePaginatedTabularReviews(options: { + projectId?: string; + search?: string; + selectionKey?: string; + scope?: TabularReviewScope; + sort?: { + key: TabularReviewSortKey; + direction: TabularReviewSortDirection; + } | null; +}) { + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [error, setError] = useState(null); + const [loadMoreError, setLoadMoreError] = useState(null); + const [selectingAllRequest, setSelectingAllRequest] = useState(false); + const [retryVersion, setRetryVersion] = useState(0); + const requestVersionRef = useRef(0); + const loadingMoreRef = useRef(false); + const loadMoreControllerRef = useRef(null); + + const { projectId, search, selectionKey, scope = "all", sort } = options; + const sortKey = sort?.key; + const sortDirection = sort?.direction; + const selectionQueryPending = + selectionKey !== undefined && selectionKey !== search; + const queryKey = JSON.stringify([ + projectId ?? null, + selectionKey ?? null, + search ?? null, + scope, + sortKey ?? null, + sortDirection ?? null, + ]); + const [selection, setSelection] = useState<{ + queryKey: string; + ids: string[]; + }>({ queryKey, ids: [] }); + const selectedReviewIds = + selection.queryKey === queryKey ? selection.ids : []; + const setSelectedReviewIds: Dispatch> = + useCallback( + (value) => { + setSelection((current) => { + const currentIds = + current.queryKey === queryKey ? current.ids : []; + const ids = + typeof value === "function" ? value(currentIds) : value; + return { queryKey, ids }; + }); + }, + [queryKey], + ); + // Owner lookup for review ids that "select all matching" pulled in but + // that haven't been paged into `reviews` yet — bulk actions (e.g. delete) + // need the owning user_id without fetching each review's full payload. + const [selectAllOwners, setSelectAllOwners] = useState<{ + queryKey: string; + ownerById: Record; + }>({ queryKey, ownerById: {} }); + const getReviewOwnerId = useCallback( + (id: string): string | undefined => { + const loaded = reviews.find((review) => review.id === id); + if (loaded) return loaded.user_id; + return selectAllOwners.queryKey === queryKey + ? selectAllOwners.ownerById[id] + : undefined; + }, + [reviews, selectAllOwners, queryKey], + ); + + useEffect(() => { + const requestVersion = ++requestVersionRef.current; + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + setReviews([]); + setHasMore(true); + setLoadingMore(false); + setError(null); + setLoadMoreError(null); + setLoading(true); + + void listTabularReviews(projectId, { + limit: PAGE_SIZE + 1, + search: search || undefined, + scope, + sortKey, + sortDirection, + signal: controller.signal, + }) + .then((rows) => { + if (requestVersion !== requestVersionRef.current) return; + const firstPage = pageRows(rows); + setReviews(firstPage.rows); + setHasMore(firstPage.hasMore); + }) + .catch((error) => { + if ( + controller.signal.aborted || + requestVersion !== requestVersionRef.current + ) + return; + console.error("[tabular reviews] failed to load", error); + setError(asError(error)); + setHasMore(false); + }) + .finally(() => { + if ( + !controller.signal.aborted && + requestVersion === requestVersionRef.current + ) + setLoading(false); + }); + return () => { + controller.abort(); + loadMoreControllerRef.current?.abort(); + }; + }, [projectId, retryVersion, scope, search, sortDirection, sortKey]); + + const loadMore = useCallback(async () => { + if (loading || loadingMoreRef.current || !hasMore) return; + + const requestVersion = requestVersionRef.current; + const offset = reviews.length; + const controller = new AbortController(); + loadMoreControllerRef.current?.abort(); + loadMoreControllerRef.current = controller; + loadingMoreRef.current = true; + setLoadingMore(true); + setLoadMoreError(null); + + try { + const rows = await listTabularReviews(projectId, { + limit: PAGE_SIZE + 1, + offset, + search: search || undefined, + scope, + sortKey, + sortDirection, + signal: controller.signal, + }); + if (requestVersion !== requestVersionRef.current) return; + + const nextPage = pageRows(rows); + setReviews((current) => { + const existingIds = new Set(current.map((review) => review.id)); + return [ + ...current, + ...nextPage.rows.filter( + (review) => !existingIds.has(review.id), + ), + ]; + }); + setHasMore(nextPage.hasMore); + } catch (error) { + if ( + !controller.signal.aborted && + requestVersion === requestVersionRef.current + ) { + console.error("[tabular reviews] failed to load more", error); + setLoadMoreError(asError(error)); + } + } finally { + if ( + requestVersion === requestVersionRef.current && + loadMoreControllerRef.current === controller + ) { + loadMoreControllerRef.current = null; + loadingMoreRef.current = false; + setLoadingMore(false); + } + } + }, [ + hasMore, + loading, + projectId, + reviews.length, + scope, + search, + sortDirection, + sortKey, + ]); + const retry = useCallback(() => { + setRetryVersion((current) => current + 1); + }, []); + + // Selects every review matching the current filters, not just the page(s) + // already loaded — a plain "select loaded rows" checkbox is misleading + // once results span more than one page. Fetches only ids (+ owner), not + // full review payloads, since that's all a bulk selection needs. + const selectAllMatching = useCallback(async () => { + if (selectionQueryPending) return; + + if (!hasMore) { + setSelectedReviewIds(reviews.map((review) => review.id)); + return; + } + + const requestVersion = requestVersionRef.current; + setSelectingAllRequest(true); + try { + const rows = await listTabularReviewIds(projectId, { + search: search || undefined, + scope, + }); + if (requestVersion !== requestVersionRef.current) return; + + setSelectAllOwners({ + queryKey, + ownerById: Object.fromEntries( + rows.map((row) => [row.id, row.user_id]), + ), + }); + setSelectedReviewIds(rows.map((row) => row.id)); + } finally { + setSelectingAllRequest(false); + } + }, [ + hasMore, + projectId, + queryKey, + reviews, + scope, + search, + selectionQueryPending, + setSelectedReviewIds, + ]); + + return { + reviews, + setReviews, + loading, + loadingMore, + hasMore, + error, + loadMoreError, + loadMore, + retry, + selectedReviewIds, + setSelectedReviewIds, + selectAllMatching, + selectingAll: selectingAllRequest || selectionQueryPending, + getReviewOwnerId, + }; +} diff --git a/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts b/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts new file mode 100644 index 000000000..59b97e2f1 --- /dev/null +++ b/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import { deleteTabularReviewsWithConcurrency } from "./deleteTabularReviewsWithConcurrency"; + +describe("deleteTabularReviewsWithConcurrency", () => { + it("limits concurrent requests and reports only confirmed deletions", async () => { + let activeRequests = 0; + let maxActiveRequests = 0; + const deleteReview = vi.fn(async (reviewId: string) => { + activeRequests += 1; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + await new Promise((resolve) => setTimeout(resolve, 1)); + activeRequests -= 1; + if (reviewId === "review-3" || reviewId === "review-7") + throw new Error("delete failed"); + }); + const reviewIds = Array.from( + { length: 10 }, + (_, index) => `review-${index}`, + ); + + const result = await deleteTabularReviewsWithConcurrency( + reviewIds, + deleteReview, + 3, + ); + + expect(maxActiveRequests).toBeLessThanOrEqual(3); + expect(deleteReview).toHaveBeenCalledTimes(10); + expect(result.deletedIds).toEqual( + reviewIds.filter((id) => id !== "review-3" && id !== "review-7"), + ); + expect(result.failedIds).toEqual(["review-3", "review-7"]); + }); + + it("deduplicates ids before deleting", async () => { + const deleteReview = vi.fn().mockResolvedValue(undefined); + + const result = await deleteTabularReviewsWithConcurrency( + ["review-1", "review-1", "review-2"], + deleteReview, + ); + + expect(deleteReview).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + deletedIds: ["review-1", "review-2"], + failedIds: [], + }); + }); +}); diff --git a/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.ts b/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.ts new file mode 100644 index 000000000..349aad51e --- /dev/null +++ b/frontend/src/app/lib/deleteTabularReviewsWithConcurrency.ts @@ -0,0 +1,41 @@ +const DEFAULT_DELETE_CONCURRENCY = 5; + +export interface BulkDeleteResult { + deletedIds: string[]; + failedIds: string[]; +} + +export async function deleteTabularReviewsWithConcurrency( + reviewIds: string[], + deleteReview: (reviewId: string) => Promise, + concurrency = DEFAULT_DELETE_CONCURRENCY, +): Promise { + const uniqueIds = [...new Set(reviewIds)]; + if (uniqueIds.length === 0) return { deletedIds: [], failedIds: [] }; + + const outcomes = new Map(); + const workerCount = Math.min( + uniqueIds.length, + Math.max(1, Math.floor(concurrency)), + ); + let nextIndex = 0; + + async function worker() { + while (nextIndex < uniqueIds.length) { + const reviewId = uniqueIds[nextIndex++]; + try { + await deleteReview(reviewId); + outcomes.set(reviewId, "deleted"); + } catch { + outcomes.set(reviewId, "failed"); + } + } + } + + await Promise.all(Array.from({ length: workerCount }, () => worker())); + + return { + deletedIds: uniqueIds.filter((id) => outcomes.get(id) === "deleted"), + failedIds: uniqueIds.filter((id) => outcomes.get(id) === "failed"), + }; +} diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 6dbaa1e6e..95d894c0b 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -1047,9 +1047,51 @@ export async function streamProjectChat(payload: { export async function listTabularReviews( projectId?: string, + pagination?: { + limit?: number; + offset?: number; + search?: string; + sortKey?: string; + sortDirection?: "asc" | "desc"; + scope?: "all" | "in-project" | "standalone"; + signal?: AbortSignal; + }, ): Promise { - const qs = projectId ? `?project_id=${encodeURIComponent(projectId)}` : ""; - return apiRequest(`/tabular-review${qs}`); + const params = new URLSearchParams(); + if (projectId) params.set("project_id", projectId); + if (pagination?.limit) params.set("limit", String(pagination.limit)); + if (pagination?.offset) params.set("offset", String(pagination.offset)); + if (pagination?.search) params.set("search", pagination.search); + if (pagination?.sortKey) params.set("sort_key", pagination.sortKey); + if (pagination?.sortDirection) params.set("sort_direction", pagination.sortDirection); + if (pagination?.scope && pagination.scope !== "all") + params.set("scope", pagination.scope); + + const qs = params.toString() ? `?${params.toString()}` : ""; + return apiRequest(`/tabular-review${qs}`, { + signal: pagination?.signal, + }); +} + +export async function listTabularReviewIds( + projectId?: string, + options?: { + search?: string; + scope?: "all" | "in-project" | "standalone"; + signal?: AbortSignal; + }, +): Promise<{ id: string; user_id: string }[]> { + const params = new URLSearchParams(); + if (projectId) params.set("project_id", projectId); + if (options?.search) params.set("search", options.search); + if (options?.scope && options.scope !== "all") + params.set("scope", options.scope); + + const qs = params.toString() ? `?${params.toString()}` : ""; + return apiRequest<{ id: string; user_id: string }[]>( + `/tabular-review/ids${qs}`, + { signal: options?.signal }, + ); } export async function createTabularReview(payload: {