diff --git a/TODOS.md b/TODOS.md new file mode 100644 index 0000000000..c85efbe52b --- /dev/null +++ b/TODOS.md @@ -0,0 +1,24 @@ +# TODOS + +## Backend: atomic create-evaluation-run endpoint + +- **What:** Add a transactional backend endpoint that creates an evaluation run plus its + scenarios and step results in a single operation (`createEvaluationRunAtomic` or + equivalent), instead of the current separate `createRuns` → `createScenarios` → + `setResults`/steps calls. +- **Why:** The frontend evaluations migration (branch `fe-chore/move-evals-to-packages`) + has to build a client-side orchestration controller with rollback (`deleteRuns` on + partial failure) purely because no atomic create exists. An atomic endpoint deletes the + entire FE rollback path and the orphaned-scenario / rollback-failure edge cases. +- **Pros:** FE `createEvaluationRun` controller collapses to one call; no orphan runs; no + rollback-failure reconciliation story; transactional integrity owned where it belongs + (the DB), per "systems over heroes." +- **Cons:** Backend work + a new endpoint contract; FE must then migrate off the + multi-call path (small follow-up). +- **Context:** During `/plan-eng-review` (2026-06-07) the FE chose controller-owned + rollback as the pragmatic FE-only solution. This TODO is the documented path to remove + that complexity later. See design doc + `~/.gstack/projects/Agenta-AI-agenta/ardaerzin-fe-chore-move-evals-to-packages-design-20260607-192109.md` + (Eng Review Decisions → run-creation orchestration). +- **Depends on / blocked by:** Backend team; relates to the FE evaluations migration + landing first (FE rollback is the interim state). diff --git a/api/oss/src/apis/fastapi/evaluations/router.py b/api/oss/src/apis/fastapi/evaluations/router.py index ca20835f85..3071a97bc5 100644 --- a/api/oss/src/apis/fastapi/evaluations/router.py +++ b/api/oss/src/apis/fastapi/evaluations/router.py @@ -2842,8 +2842,9 @@ async def query_simple_queues( windowing = compute_next_windowing( entities=queues, - attribute="id", + attribute="created_at", windowing=queue_query_request.windowing, + order="descending", ) return SimpleQueuesResponse( diff --git a/api/oss/src/dbs/postgres/evaluations/dao.py b/api/oss/src/dbs/postgres/evaluations/dao.py index e2b60f9228..c4fe75f207 100644 --- a/api/oss/src/dbs/postgres/evaluations/dao.py +++ b/api/oss/src/dbs/postgres/evaluations/dao.py @@ -2829,7 +2829,9 @@ async def query_queues( stmt = apply_windowing( stmt=stmt, DBE=EvaluationQueueDBE, - attribute="id", # UUID7 + # created_at, not id: backfilled queues carry back-dated + # timestamps, so UUID7 id order diverges from created_at. + attribute="created_at", order="descending", # jobs-style windowing=windowing, ) diff --git a/api/oss/src/dbs/postgres/shared/utils.py b/api/oss/src/dbs/postgres/shared/utils.py index 2ebc5f4bd8..543f6298a1 100644 --- a/api/oss/src/dbs/postgres/shared/utils.py +++ b/api/oss/src/dbs/postgres/shared/utils.py @@ -93,7 +93,13 @@ def apply_windowing( if order_attribute is id_attribute: stmt = stmt.order_by(windowing_order) else: - stmt = stmt.order_by(windowing_order, id_attribute) + # The id tie-break must follow the primary direction: the descending + # cursor filters `id < next` on equal timestamps, so ties must be + # emitted in descending id order (and ascending for `id > next`). + if windowing_order is descending_order: + stmt = stmt.order_by(windowing_order, id_attribute.desc()) + else: + stmt = stmt.order_by(windowing_order, id_attribute.asc()) if windowing.limit: stmt = stmt.limit(windowing.limit) diff --git a/docs/designs/entity-state-consolidation-plan.md b/docs/designs/entity-state-consolidation-plan.md new file mode 100644 index 0000000000..468bb1937b --- /dev/null +++ b/docs/designs/entity-state-consolidation-plan.md @@ -0,0 +1,147 @@ +# OSS entity-state → `@agenta/entities` molecules consolidation + +Status: **PLAN — not started.** A standalone platform initiative, surfaced while executing +WP-4 of the [evaluations→packages migration](./evaluations-packages-migration-plan.md). It is a +**prerequisite for WP-4e** (moving the eval-run atoms to `@agenta/evaluations`), but it is much +larger than the eval migration and must be run as its own deliberate, human-in-the-loop effort. + +Branch context discovered on: `fe-chore/move-evals-to-packages`, 2026-06-10. + +--- + +## 0. Why this exists (the trigger) + +WP-4e (move `EvalRunDetails/atoms` → `@agenta/evaluations`) is blocked: ~18 of those atoms import +OSS entity-state (`@/oss/state/entities/{testcase,testset,shared}`). That OSS entity-state is a +**separate, older, DIVERGENT implementation that parallels the modern `@agenta/entities` molecules +that already exist** — not the same code awaiting a move. So WP-4e cannot "promote" it without +either (a) duplicating the package molecules, or (b) re-platforming OSS consumers onto the existing +molecules. (b) is the right end-state and is what this plan covers. + +**Two ways out of the WP-4e block:** +1. **Injection seams** (recommended for the eval migration in isolation): the eval atoms receive + testcase/testset/References/workspace data as injected inputs from the OSS `-ui` provider; the + OSS entity layer is untouched. Unblocks WP-4e without this consolidation. +2. **This consolidation** (the broader platform goal): kill the divergent OSS entity-state, standardize + the whole app on the `@agenta/entities` molecules. Worthwhile debt-reduction, but app-wide. + +This doc captures (2). + +--- + +## 1. The core hazard (read first) + +**`tsc` will NOT catch the biggest regression risk.** The OSS testcase entity uses a *flattened* +shape (`FlattenedTestcase` — user fields hoisted to the row root); the package `testcaseMolecule` +uses a *nested* shape (`data: { ...fields }`). Re-pointing an importer from the OSS flat shape to the +package nested shape **compiles cleanly but silently breaks rendering at runtime** (cells read +`row.country`; package gives `row.data.country`). ~273 importers across **playground, testsets, +annotation, eval, drawers, settings** consume this. Therefore: + +- **No step of this plan is "done" on `tsc`/`lint` green alone** — each importer-touching step needs + **runtime/behavioral QA** of the affected feature. +- The OSS-deletion steps (C7) are **irreversible** and gated on that QA across all feature areas. + +This is precisely why it must be human-in-the-loop, not an autonomous grind. + +--- + +## 2. Scope (verified) + +| | OSS (to retire) | Package (target) | +|---|---|---| +| shared infra | `state/entities/shared/` — `createEntityController` (743), `createEntityDraftState` (341), `createPaginatedEntityStore` (562), `createStatefulEntityAtomFamily` (168), utils — **~1,553 LOC** | `@agenta/entities/src/shared/` — `molecule/*`, `paginated/*` (createPaginatedEntityStore 680, createInfiniteTableStore 464), utils | +| testset | `state/entities/testset/` — revisionEntity (567), store (455), controller (650), testsetController (245), paginatedStore (411), mutations (387), revisionSchema (166), dirtyState (222) — **~2,790 LOC** | `@agenta/entities/src/testset/state/` — revisionMolecule (1,110), testsetMolecule (786), store (769), mutations (914), revisionTableState (511), paginatedStore (234) | +| testcase | `state/entities/testcase/` — 15 files incl. testcaseEntity (949), schema (482), columnState (661), paginatedStore (350), controller (370), queries (255), mutations (269), columnPathUtils (169) — **~5,292 LOC** | `@agenta/entities/src/testcase/state/` — molecule (1,008), store (1,005), paginatedStore (349), dataController (253), prefetch (138) | + +**Totals:** ~9,573 LOC OSS to delete · ~273 importer files to re-point · ~331 files touched · +**est. 14–18 engineering days.** + +**Coverage verdict:** the package molecules are a **genuine superset** capability-wise; the gap is +mostly *organizational* (where things live) + the **data-format** and **API-shape** divergences below. + +--- + +## 3. Gap details + divergences + +### 3.1 shared infra — **coverage ~100%, risk LOW** +Every OSS export has a package equivalent (`createEntityController`, `createEntityDraftState`, +`createPaginatedEntityStore`, `EntityController*`/`DrillIn*`/`PathItem` types). Package uses a +`createMolecule` + `withController` composition layer over the same primitives; the OSS controller-only +API maps onto `molecule.controller(id)`. No OSS-only symbols. Package additionally has entity-relations +(OSS lacks) — additive, no conflict. + +### 3.2 testset — **coverage ~95%, risk LOW–MEDIUM** +`revision`/`testset` controllers → `revisionMolecule`/`testsetMolecule` (molecule exposes +`atoms/selectors/actions/get/set`; controller-style use still works). Column dirty-state → +`revisionMolecule.tableReducers`. OSS-only **thin helpers to port** (~50 LOC): `getVersionDisplay`, +`isV0Revision`, `normalizeRevision` (package likely has normalization already). + +### 3.3 testcase — **coverage ~80%, risk HIGH** +The hard one. Divergences: +- **Data format:** `FlattenedTestcase` (flat) vs package nested `data` — see §1. **Decision required.** +- **Column ops:** OSS has *testcase-level* column atoms (`currentColumnsAtom`, `addColumnAtom`, + `renameColumnAtom`, `deleteColumnAtom`, `expandedColumnsAtom`); package moved these to *revision + level* (`revisionMolecule.tableReducers.*`, `revisionMolecule.atoms.effectiveColumns`). Re-points + must thread `revisionId` and may change read-only-vs-driven semantics. +- **OSS-only utils to port/refactor** (~300 LOC): `flattenTestcase`, `extractTestcaseUserData`, + `deriveTestcaseColumnKeys` (package has `extractColumnsFromData`), `columnPathUtils` (package has + `DataPath`/`getValueAtPath` in `@agenta/shared/utils`). +- Package adds `testcaseDataController` + `prefetchTestcasesByIds` (additive). + +**The data-format decision (make first):** +- **Option A** — keep `FlattenedTestcase`; add flat↔nested converters at the boundary. Lower importer + churn, but perpetuates two shapes + conversion cost. +- **Option B (recommended)** — refactor importers to the package nested shape; delete the flat shape. + Cleaner long-term; higher one-time churn; **this is the §1 silent-regression surface** — gate on QA. + +--- + +## 4. Leaves-first execution plan (C1–C7) + +Internal cascade (leaf → root): `shared` → `testcase` → `testset` → importers. Each step: reconcile/port, +re-point, build+lint, **and behavioral-QA the touched features**; commit; only then proceed. + +- **C1 — shared controller infra.** Reconcile OSS consumers onto `@agenta/entities/shared` molecule + primitives. Mostly direct re-point (+ thin adapters if an API differs). ~1 day, LOW risk. No OSS delete yet. +- **C2 — testset schema + state.** Re-point onto `revisionMolecule`/`testsetMolecule`; port the 3 thin + version helpers. ~1 day, LOW–MED. Blocks on C1. +- **C3 — testcase schema + state + DATA FORMAT.** The crux. Execute the §3.3 data-format decision; port + `flatten`/`extract` utils or refactor importers; verify query/entity/draft/cell families map to + `testcaseMolecule`. ~2–3 days, **HIGH**. Blocks on C1 (+ C2 schema). Prototype the EvalRunDetails ETL + re-point first as the canary. +- **C4 — testcase column ops → revision level.** Re-point `currentColumnsAtom`/`add|rename|deleteColumnAtom` + → `revisionMolecule.tableReducers`/`effectiveColumns(revisionId)`. ~1 day, MED. Blocks on C2,C3. +- **C5 — mutations.** Reconcile save/clear/batch onto molecule actions + package mutation APIs. ~0.5 day, LOW. +- **C6 — re-point all ~273 importers**, phased by feature area (testsets ~60 → testcases ~60 → shared + ~60 → cross-feature ~90). Run feature QA after EACH phase. ~5–7 days, MED (large surface). +- **C7 — delete OSS `state/entities/{testcase,testset,shared}`** (~9.5k LOC). Irreversible; gated on + full-app QA passing. ~0.5 day. +- **Integration testing** across testsets UI, playground, eval details, annotations. ~2–3 days. + +--- + +## 5. Risks (and why QA — not tsc — is the gate) + +1. **Flat vs nested testcase data (HIGH, tsc-invisible)** — §1. Mitigate: Option B + ETL canary + + per-feature runtime QA + before/after screenshots; consider a temporary parallel-render check. +2. **Column ops moved to revision level (MED)** — audit every column-atom importer; thread `revisionId`; + QA column add/rename/delete in testsets UI. +3. **Molecule vs controller API (MED)** — both valid; controller-style use maps onto the molecule; + spot-check direct-controller consumers. +4. **273-file re-point surface (MED)** — phase by feature; full test run + manual QA per phase; rely on + strict TS to catch *structural* misses (but NOT the data-format ones). +5. **Missing testcase utils (LOW–MED)** — port `flatten`/`extract` or eliminate via Option B. + +--- + +## 6. Relationship to the evaluations migration (WP-4) + +- WP-4e (eval atom move) is **blocked** on this consolidation **only if** we choose to move the eval + atoms onto the package molecules directly. The **injection-seam** alternative (§0 option 1) unblocks + WP-4e *without* this consolidation and is the recommended path for completing the eval migration in + isolation. +- If this consolidation lands first, WP-4e becomes a clean re-point (eval atoms use the package + molecules like every other consumer). +- Either way, this is **not** part of WP-4's scope and should not be grafted into it; it gets its own + branch, review, and QA matrix. diff --git a/docs/designs/evaluations-packages-migration-plan.md b/docs/designs/evaluations-packages-migration-plan.md new file mode 100644 index 0000000000..c3e037d9dc --- /dev/null +++ b/docs/designs/evaluations-packages-migration-plan.md @@ -0,0 +1,863 @@ +# Evaluations → packages migration plan + +Branch: `fe-chore/move-evals-to-packages` + +Status: **PLAN — locked structure, not yet executed.** This document is the source of +truth for the migration. If an action you're about to take is not traceable to a Work +Package below, stop and re-read §0. + +--- + +## 0. Guardrails (read first, every session) + +This migration has gone sideways once already (a whole session was spent Fern-migrating the +OSS *service/HTTP layer* — which was never the goal — instead of relocating *state/engine +logic*). These rules exist to prevent that. + +**The goal, in one sentence:** unify the evaluation-run *state/engine* (scenario session, +metrics, columns, list/table store, run creation) into a layered package architecture +(`entities ← evaluations ← annotations`, plus `-ui` mirrors), so that **human evaluations +and annotation queues become presets over one evaluation engine** — then delete the OSS +duplicates. + +**Cardinal rules:** + +1. **Move/extract, do NOT rewrite.** The engine already exists twice (in `@agenta/annotation` + and in OSS `EvalRunDetails`/`EvaluationRunsTablePOC`). We extract the cleaner copy + (annotation) into `evaluations`, rename as needed, and re-point consumers. Writing + new logic is a last resort, only for genuine gaps named in §6. +2. **Annotation stays green the entire time** (it is the source of truth AND it ships). Every + Work Package keeps `@agenta/annotation` + `@agenta/annotation-ui` + their routes working. +3. **OSS is deleted only after parity is proven** against the OSS regression baseline (§4). + No OSS eval view/atom is removed until the package-driven replacement is regression-tested + against it. +4. **One generic, configurable table** in `evaluations-ui` — move the existing + `AnnotationQueuesView` into it (with renaming/config props), do not author a second table. +5. **`entities` stays as-is for entity *definitions*.** Each entity is a molecule/api/core in + `entities`; the *wiring* of entities into evaluation functionality goes in `evaluations`. + Do not put cross-entity orchestration in `entities`. +6. **No half-and-half / no bridges.** When a capability moves to a package, the OSS shell is + deleted in the same Work Package (or explicitly tracked as debt with a deletion WP). +7. **Clean up after yourself — zero OSS residue (HARD gate).** After this migration, OSS must + contain **no eval-related services, utils, or data-layer atoms** — only thin route handlers + and `-ui` providers. Every WP that moves a capability **deletes its OSS counterpart in the + same WP**; deletion is never deferred to "later." The migration is NOT done until the + cleanup ledger in §7 is fully checked off and its verification commands return empty. If + you finish a WP and left an OSS service/atom/util behind, the WP is not done. + +**Explicit non-goals (do NOT do these as part of this work):** + +- Do NOT Fern-migrate or refactor the *legacy* evaluations bridge + (`oss/src/services/evaluations/api/index.ts` — `_Evaluation` types, `GET /evaluations`, + `POST /simple/evaluations/`). Different domain; separate effort. +- Do NOT take on online-evaluations (`services/onlineEvaluations`) beyond what the shared + engine naturally covers; it has its own controller plan. +- Do NOT change backend models or regenerate the Fern client (settled: the FE aligns to the + real contract; see the prior session's findings). +- Do NOT build a new table, a new paginated store, a new session controller, or a new + metrics processor. They exist — move them. + +**Anti-stray check** — before writing code, answer in your head: +*"Which Work Package is this? What existing package code am I moving? What keeps annotation +green? What OSS thing does this let me delete, and how will I prove parity first?"* If you +can't answer all four, you're about to stray. + +--- + +## 1. The unified entity model + +There is ONE core entity: the **evaluation run** — +`run → scenarios → results → metrics`, with `data.steps` (`input` | `invocation` | +`annotation`) and `data.mappings`. A run's *kind* is a **projection**, derived from step +origins + flags (see `deriveEvaluationKind`): + +| Kind | How it's identified | +|---|---| +| auto eval | invocation steps + `annotation` steps with `origin="auto"` | +| human eval | `annotation` steps with `origin="human"` | +| annotation queue | human-eval run with `is_queue=true` (+ assignment semantics) | +| online eval | `is_live=true` (or `meta.source="online_evaluation_drawer"`) | + +**Strategic driver:** human evaluations will be *replaced by* annotation queues. They are the +same entity with different flags — so the engine must be kind-agnostic, and "annotation +queue" is a thin preset on top. + +--- + +## 2. Target package architecture + +``` +shared ← ui ← entities ← evaluations ← annotations + │ │ + └ evaluations-ui ← annotations-ui +``` + +Dependency rule: arrows only point left/down. `annotations` MAY depend on `evaluations`; +`evaluations` MUST NOT depend on `annotations`. + +| Package | Owns | Status | +|---|---|---| +| `@agenta/entities` | Each entity: `evaluationRun`, **`evaluationScenario`** (done), `evaluationResult`, `evaluationMetric`, `evaluationQueue`/`simpleQueue`, `annotation`, `workflow` (evaluators), `testcase`/`testset`/`trace`. **Entity definitions only** — the `evaluationRun/etl` (hydration/mapping/filtering) MOVES to `evaluations` (see WP-3.5; decision reversed 2026-06-09). | Mostly exists | +| `@agenta/evaluations` | Generic *wiring*: run creation (exists), the **run list store**, the **scenario session engine**, **metrics processing**, the **eval-run ETL** (scenario hydration, mapping/column resolution, **client-side filtering** — moved from `entities/evaluationRun/etl` + OSS `EvalRunDetails/etl`, the ahead impl), kind derivation, status rollup. Kind-agnostic. | Has run-creation only; rest extracted here | +| `@agenta/annotations` (rename/refocus current `@agenta/annotation`) | The queue delta only: annotation submit form, queue assignment, focus-mode, testset write-back. Depends on `evaluations` (and thereby GAINS the ETL filtering it lacks today). | Exists but "upside-down" — see §3 | +| `@agenta/evaluations-ui` (NEW) | Run list table (ONE generic configurable table, moved from `AnnotationQueuesView`), run detail view, scenario table, metric cells, `CreatedByCell`, **the ETL filter bar / column headers / resolved cells** (moved from OSS `EvalRunDetails/etl`). | New; populated by moving existing UI | +| `@agenta/annotations-ui` (current `@agenta/annotation-ui`) | Queue-specific UI: submit form/session, `CreateQueueDrawer`, `AddToQueuePopover`, the run table configured with a "queue" preset. Depends on `evaluations-ui`. | Exists; sheds generic parts | + +--- + +## 3. The core realization: `@agenta/annotation` is upside-down + +`@agenta/annotation` currently holds the **generic evaluation engine**, flavored as +"annotation": + +- `annotationSessionController.ts` (~3.7k lines) — scenario navigation, scenario data + (trace/steps/testcase/rootSpan), metrics (run-level + per-scenario), column defs, statuses, + views — **all generic eval-run logic** — plus a thin annotation shell. +- `annotationFormController.ts` (~1.7k lines) — generic metric/schema extraction + (`getOutputsSchema`, `getMetricFieldsFromEvaluator`, `getMetricsFromAnnotation`) + the + annotation submit form. + +Meanwhile OSS `EvalRunDetails/atoms` reimplements the SAME generic engine (~38 atoms across +`run.ts`, `scenarioSteps.ts`, `scenarioColumnValues.ts`, `metrics.ts`, `runMetrics.ts`, +`traces.ts`, `references.ts`) directly on the molecules + `etl`, never importing +`@agenta/annotation`. + +So this migration = **extract the generic engine out of `@agenta/annotation` down into +`@agenta/evaluations`**, leave the annotation delta behind (now depending on `evaluations`), +then **re-point the OSS eval views at `evaluations`/`evaluations-ui` and delete the OSS +duplicates** — proving parity against OSS first. + +### 3.1 Controller decomposition (the extraction map) — RE-SCOPED 2026-06-09 (verified from code) + +**Verified before any cut (no assumptions):** +- The session engine is founded on `simpleQueueMolecule`: `activeRunId ← simpleQueueMolecule.runId(queueId)`, + `rawScenarioRecords ← simpleQueueMolecule.scenarios(queueId)`, + `scenariosQuery ← simpleQueueMolecule.scenariosQuery(queueId)`. +- The two consumers source the scenario LIST from **different endpoints**: + annotation → `POST /simple/queues/{id}/scenarios/query` (queue-scoped, optional `user_id` + annotator filter → may be a **subset** of run scenarios); EvalRunDetails → `POST + /evaluations/scenarios/query` by `run_id` (run-scoped, windowed). Both return + `EvaluationScenario`-shaped rows. +- Scenario *data* (steps/results/metrics) is derived by `{projectId, runId, scenarioId}` from + the evaluationRun/result/metric molecules in BOTH; trace/testcase refs are read off the + scenario row itself (source-agnostic). + +**Consequence — the engine is parameterized by an injected SCENARIO SOURCE, not a molecule.** +The `evaluations` session engine MUST NOT hardcode `simpleQueueMolecule` or +`evaluationScenarioMolecule`. It takes `{projectId, runId, scenarios[], scenariosQuery}` (the +source) and owns the rest. Annotation injects the queue source (user-scoped); the eval-run +view injects the run source (`evaluationScenarioMolecule`/`/evaluations/scenarios/query`). + +`annotationSessionController` → + +- **Generic → `evaluations` (the TRULY-shared core, both consumers derive this):** + scenario-DATA selectors keyed by `{projectId, runId, scenarioId}` — `scenarioStepsQuery`, + `scenarioTraceRef`, `scenarioTestcaseRef`, `scenarioTraceQuery`, `scenarioRootSpan`, + `scenarioMetrics`, `scenarioMetricsQuery`, `scenarioMetricForEvaluator`; column/evaluator + derivations — `evaluatorIds`, `evaluatorRevisionIds`, `evaluatorStepRefs`, + `annotationColumnDefs` (rename → `evaluatorColumnDefs`), `listColumnDefs`, `traceInputKeys`, + `testcaseInputKeys`, `testcaseData`. These delegate to the entities molecules. +- **Generic-but-source-PARAMETERIZED → `evaluations` session engine:** `activeProjectId`, + `activeRunId`, `currentScenarioId`, `currentScenarioIndex`, `focusedScenarioId`, + `scenarioIds`, `navigableScenarioIds`, `progress`, `hasNext`, `hasPrev`, + `isCurrentCompleted`, `scenarioStatuses`, `activeView`, `completedScenarioIds`, + `scenarioOrder`; actions `openSession`, `navigateNext/Prev/ToIndex`, `syncScenarioOrder`, + `markCompleted`, `completeAndAdvance`, `closeSession`, `setActiveView`, `applyRouteState`. + The scenario LIST + its query state are INJECTED (annotation: queue source; eval view: run + source) — `scenarioRecords`/`scenariosQuery` are NOT owned by the engine. +- **Annotation-specific → stays in `annotations` (injects the queue source + owns the delta):** + `activeQueueId`, `activeQueueType`, the queue→engine wiring (feeds queue scenarios + runId + into the engine), `queueName`/`queueKind`/`queueDescription`, `hideCompletedInFocus`, + `focusAutoNext` (focus-mode UX), `scenarioAnnotations*`, `scenarioAnnotationByEvaluator` + (annotation entity reads), all add-to-testset (`defaultTargetTestsetName`, + `pendingTestsetSelection*`, `addToTestset*`, `selectedScenarioIds`, `canSyncToTestset`, + `syncToTestsets`, `addScenariosToTestset`). +- **Regression risk to watch:** the queue source applies user-scoping; do NOT swap annotation + to a run-scoped source. Annotation keeps feeding the QUEUE scenarios into the engine; only + the engine code is shared, not the source. + +`annotationFormController` → + +- **Generic → `evaluations`:** `getOutputsSchema`, `getMetricFieldsFromEvaluator`, + `getMetricsFromAnnotation`, `evaluators`, `evaluatorResolution`, `effectiveMetrics`, + `baseline`. +- **Annotation submit → stays in `annotations`:** `updateMetric`, `submitAnnotations`, + `resetEdits`, `hasPendingChanges`, `hasFilledMetrics`, `isSubmitting`, `submitError`, + `setScenarioContext`, `clearFormState`. + +--- + +## 4. Source-of-truth & regression baselines + +- **Extract FROM (source of truth):** `@agenta/annotation` + `@agenta/annotation-ui` — for the + session/scenario/metrics engine. +- **EXCEPTION — the ETL filtering feature:** here OSS `EvalRunDetails/etl` is the source of + truth; **annotation has no filtering at all** (verified — it imports none of the etl + filtering). So the ETL (scenario hydration + mapping/column resolution + client-side + filtering) is extracted from OSS, not annotation, in WP-3.5, and moved into `evaluations` / + `evaluations-ui`. Annotation queues GAIN filtering by depending on `evaluations`. +- **Keep GREEN throughout (live annotation consumers):** + `web/oss/src/pages/.../annotations/index.tsx`, `.../annotations/[queue_id].tsx`, + `web/oss/src/components/Annotations/AnnotationTraceContent.tsx`, + `.../AnnotationTestcaseContent.tsx`. +- **Regression BASELINE (OSS to be deleted — prove parity before removal):** + `EvalRunDetails` + `EvaluationRunsTablePOC`, rendered at: + - `web/oss/src/pages/.../evaluations/results/[evaluation_id]/index.tsx` + - `.../evaluations/single_model_test/[evaluation_id]/index.tsx` + - `.../apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx` + - `.../apps/[app_id]/overview/index.tsx` + - EE equivalents under `web/ee/src/pages/...evaluations/results/[evaluation_id]`. + +--- + +## 5. Work Packages (sequenced; each keeps annotation green) + +Each WP lists: **Move** (what/from→to), **DoD** (definition of done), **Integration test** +(real API, real atoms), and **Regression gate**. Do them in order. Do not start a WP until +the previous one's DoD + tests + gate pass. + +> **Testing is part of every WP's DoD — non-negotiable (see §8).** Every WP that moves +> state/logic ships a **real-API integration test that drives the SHIPPED atoms/molecules/ +> controllers** — never a test-local replica of the logic. Setup may seed data via the raw +> Fern client, but assertions go through the real package surface. A WP without its +> integration test is NOT done. (Why: this migration's own mapping-kind bug shipped because a +> test hand-built `mappings:[]` instead of calling the real `buildRunConfig` — it passed +> against broken code. Never again.) +> +> Pre-flight (every WP touching package manifests): keep all `package.json` + lock changes in +> ONE commit (prettier hook rewrites the lock otherwise). Respect import hierarchy. `no any`. +> Run `pnpm --filter build` + `lint` before committing. + +### WP-0 — Scaffold + entity promotion (no behavior change) +- **Move:** create `@agenta/evaluations-ui` package (manifest, build, lint, test config, + empty `src/index.ts`) registered in OSS+EE `next.config` + `ee/package.json` (mirror the + `@agenta/evaluations` registration done this session). Promote `evaluationScenario` to a + first-class `entities` module (molecule/api/core) from the half-schema currently under + `evaluationRun`. +- **DoD:** packages build; `evaluationScenario` is a first-class molecule. +- **Integration test (real API, real atoms):** drive the **shipped `evaluationScenario` + molecule** (its api + atom selectors) against a real run's scenarios — create → query → + read selectors → assert; like the existing eval-run integration suite. Not a replica schema. +- **Regression gate:** full entities unit (591+) green; eval integration green; OSS/EE build. + +### WP-1 — Extract the scenario **session engine** → `@agenta/evaluations` (injected source) +- **Move (per the re-scoped §3.1):** extract the generic engine from `annotationSessionController` + into `evaluations`, in two parts: + 1. **Scenario-data selectors** keyed by `{projectId, runId, scenarioId}` (steps/results/ + metrics/trace/testcase/columns/evaluator refs) — pure delegations to the entities + molecules. These are the truly-shared core. + 2. **Session engine** that takes an **injected scenario source** — `{projectId, runId, + scenarios[], scenariosQuery}` — and owns navigation/progress/current/focus/view/completion. + It MUST NOT import `simpleQueueMolecule` or `evaluationScenarioMolecule` (source-agnostic). +- `@agenta/annotation` keeps the annotation shell, **feeds the QUEUE scenario source** + (`simpleQueueMolecule`, user-scoped — do NOT swap to a run-scoped source) + runId into the + engine, and imports the generic engine from `evaluations` (add the dependency). Rename + annotation-flavored names to kind-agnostic (`openQueue`→`openSession`, + `annotationColumnDefs`→`evaluatorColumnDefs`) with temporary re-exports in `annotation`. +- **DoD:** `@agenta/annotation` controller is now a thin wrapper over `evaluations`; no logic + duplicated. +- **Integration test (real API, real atoms):** drive the **shipped `evaluations` session + controller** (its real atoms/selectors — `scenarioIds`, `currentScenarioId`, navigate + actions, `scenarioStatuses`, `scenarioMetrics`, `evaluatorColumnDefs`) against a real + populated run; extend the existing harness. Assert through the controller surface, not a + copy. Worker-computed metrics via the real-project read-only smoke. Because the annotation + controller is now a wrapper, the existing annotation tests also exercise the moved engine. +- **Regression gate:** annotation routes manually QA'd green (open queue, navigate scenarios, + metrics render); annotation package tests green. + +### WP-2 — Extract metric/schema extraction (form controller generic half) → `evaluations` +- **Move:** `getOutputsSchema`, `getMetricFieldsFromEvaluator`, `getMetricsFromAnnotation`, + `evaluators`, `evaluatorResolution` into `evaluations`. The annotation submit form stays in + `annotation`, importing these. +- **DoD:** no metric/schema extraction logic left duplicated. +- **Integration test (real API, real atoms):** seed a real run with evaluator (annotation) + steps, then drive the **shipped `evaluations` metric/schema functions** (`getMetricFieldsFromEvaluator`, + `getOutputsSchema`, `getMetricsFromAnnotation`, `evaluatorResolution`) against the real + evaluator workflow — assert the metric fields/schema resolve. Do NOT re-derive the schema in + the test. Worker-computed metric values verified via the real-project read-only smoke. +- **Regression gate:** annotation submit flow QA'd (fill metric → submit → persists). + +### WP-3 — Move the run **list store + table** → `evaluations` / `evaluations-ui` +- **Move:** the queue list store (`simpleQueue/paginatedStore` pattern) generalized into an + `evaluations` run-list store; **move `AnnotationQueuesView` into `evaluations-ui` as ONE + generic, configurable table** (config props for columns/cells/filters/kind preset). Cells + (`CreatedByCell`, `EvaluatorNamesCell`, `QueueProgressCell`) move with it. `annotations-ui` + renders the table with a "queue" preset. +- **DoD:** one table component; annotation queue list renders via the generic table + preset; + no second table authored. +- **Integration test (real API, real atoms):** drive the **shipped `evaluations` run-list + store** (its real atoms — list query, kind/status filters, search term, pagination/windowing + cursor) against real runs/queues; assert the returned, parsed rows. Reuse the populated-run + seeding + the real-project read-only smoke. Do NOT reimplement the list query in the test. +- **Regression gate:** annotation queue list QA'd (list, filter, search, pagination, + created-by, progress). + +### WP-3.5 — Move the eval-run ETL (hydration / columns / filtering) → `evaluations` + `evaluations-ui` +This is the one capability where **OSS is ahead of annotation** (annotation has no filtering), +so the source of truth is OSS `EvalRunDetails/etl`, not annotation (see §4 exception). +- **Move:** + - **Headless primitives** `entities/evaluationRun/etl` (`hydrateScenariosTransform`, + `resolveMappings`/`groupRunColumns`, `rowPredicateFilter`/`filterSchema`/ + `predicateToEntitySlices`, `realScenarioSource`, cache fetchers) → `@agenta/evaluations`. + First verify nothing in `entities/*` source (only a test) imports it, so there's no + `entities → evaluations` cycle. Update the `@agenta/entities/evaluationRun/etl` subpath + consumers to the new `evaluations` path. + - **Filtering state/hooks (CLEAN subset only)** from OSS `EvalRunDetails/etl/` → + `@agenta/evaluations`: `scenarioFilterState`, `useScenarioFilter`, `useHydrateScenarios`, + `useScopeChangeEviction`, `useCellMaterialization`, `cellMaterializerContext`. These import + only entities + `@agenta/evaluations/etl` + react/jotai (verified) — no OSS atom layer. + +> **RE-SCOPED 2026-06-10 (atom dependency inversion — verified from code).** The remaining ETL +> pieces — the **column hooks** `useEtlColumns`/`columnValueTypes`/`useScenarioLiveUpdates` and +> the **filtering UI** `ScenarioFilterBar`/`EtlColumnHeader`/`cells/EtlResolvedCell` — import the +> OSS `EvalRunDetails/atoms/*` + `state/*` layer (`atoms/tableRows`, `atoms/table`, +> `atoms/compare`, `atoms/references`, `atoms/table/evaluators`, `state/rowHeight`, +> `evaluationPreviewTableStore`). That atom layer is WP-4 scope and transitively pulls in most of +> the OSS eval data layer (`lib/evaluations`, `services/evaluations`, `usePreviewEvaluations`, +> `References/atoms`, `EvaluationRunsTablePOC/atoms`, …). So these ETL pieces **CANNOT move before +> the atom layer**, and the atom-layer move IS WP-4. They are therefore **moved in WP-4**, not +> here. WP-3.5 ships only the headless primitives (done, 3.5a) + the clean filtering hooks. +> Consequently the OSS `EvalRunDetails/etl/` dir is NOT fully deleted in WP-3.5 — only its clean +> files move; the entangled remainder + the dir deletion happen in WP-4. + +- **DoD (re-scoped):** the headless ETL primitives + the clean filtering hooks live in + `@agenta/evaluations`; the OSS consumers (incl. the still-OSS entangled etl files) re-point to + the package; no `entities → evaluations` cycle. The filtering UI + column hooks + the OSS + `EvalRunDetails/etl/` deletion move to WP-4 (gated on the atom-layer move). +- **Integration test (real API, real atoms):** drive the **shipped `evaluations` ETL** — + hydrate a real run's scenarios and apply a real `rowPredicateFilter`/`filterSchema` over the + hydrated rows; assert the filtered set. Use real run data; do NOT hand-roll the filter. +- **Regression gate:** scenario filtering QA'd on the eval run detail (apply/clear filters, + column resolution) against the OSS baseline (§4) — this is parity for an OSS-sourced feature. + +### WP-4 — Point OSS eval views at the packages; prove parity; DELETE OSS dups +- **Move:** re-point `EvaluationRunsTablePOC` (run list) and `EvalRunDetails` (run detail + + scenario table + metrics) to consume the `evaluations`/`evaluations-ui` engine + table. + Then **delete** the OSS eval atoms (~38 in `EvalRunDetails/atoms`, the `EvaluationRunsTablePOC` + store/atoms) and the now-thin OSS service shells from the prior session. +- **Absorbs from WP-3.5 (re-scoped 2026-06-10):** the atom-coupled ETL pieces deferred from + WP-3.5 — column hooks `useEtlColumns`/`columnValueTypes`/`useScenarioLiveUpdates` → + `@agenta/evaluations`; filtering UI `ScenarioFilterBar`/`EtlColumnHeader`/`cells/EtlResolvedCell` + → `@agenta/evaluations-ui` — move together with the `EvalRunDetails/atoms`+`state` layer they + depend on, and the OSS `EvalRunDetails/etl/` dir is deleted here. +- **DoD:** OSS eval views are thin route handlers + a `-ui` provider supplying inputs (like + `AnnotationUIProvider`); the ~50 OSS eval atom files are gone; no `@agenta/*` ← OSS bridge. +- **Regression gate (the big one):** parity vs the §4 OSS baseline on every listed route — + auto eval results, human eval, single-model test, app overview, EE results — covering: run + list (filters/search/sort/delete), run detail (scenario table, columns, metric columns + run-level + temporal, annotate drawer write-back + status rollup). Use integration tests at + the atom/API layer + the real-project read-only smoke + a manual UI matrix. Capture + before/after screenshots per route. + +#### WP-4 execution DAG (leaves-first, mapped 2026-06-10) + +No circular deps between subsystems; everything flows lib → services → hooks → atoms → state → +etl/UI → views. ~12k LOC across 60+ files. Move leaves first, commit each, parity-gate before +ANY deletion. Sub-steps: + +- **4a** `oss/lib/evaluations/` (buildRunIndex, utils/{evaluationKind,metrics}, types, legacy) → + `@agenta/evaluations`. ⚠️ Verify: it imports OSS-local legacy (`components/pages/evaluations/ + cellRenderers`, `services/evaluations/api`) — untangle or carry; and resolve the §6 question + (does `buildRunIndex` overlap/collapse into the already-moved `resolveMappings`/`groupRunColumns`?). +- **4b** `oss/services/evaluations/` (results/scenarios/invocations api + workerUtils) → `@agenta/evaluations`. +- **4c** `oss/services/evaluationRuns/` (createEvaluationRunConfig) → `@agenta/evaluations` (note buildRunConfig already exists there — dedup). +- **4d** `oss/lib/hooks/usePreviewEvaluations/` → `@agenta/evaluations` (blocks on 4a, 4c). +- **4e** `EvalRunDetails/atoms/` (~22 movable files + `evaluationPreviewTableStore`) → `@agenta/evaluations` (blocks on 4a, 4d). `runInvocationAction.ts` couples to EvaluationRunsTablePOC — inject the invalidation callback (don't hard-import). +- **4f** `EvalRunDetails/state/` → `@agenta/evaluations` (blocks on 4e). +- **4g** deferred ETL: column hooks `useEtlColumns`/`columnValueTypes`/`useScenarioLiveUpdates` → + `@agenta/evaluations`; UI `ScenarioFilterBar`/`EtlColumnHeader`/`EtlResolvedCell` → `@agenta/evaluations-ui` (blocks on 4e, 4b). +- **4h** re-point `EvalRunDetails/Table.tsx` + index → packages (blocks on 4e/4f/4g). +- **4i** re-point `EvaluationRunsTablePOC` (+ its export layer) → packages atoms. +- **4j** resolve `runInvocationAction` coupling (callback injection). +- **4k** DELETE OSS dups — only after 4h/4i green. Point of no return. +- **4l** PARITY GATE: integration tests at atom/API layer + real-project smoke + **manual UI + matrix + before/after screenshots** across all §4 routes. No deletion sign-off without it. + +Stays in OSS (broadly-shared, NOT eval-specific; packages import via `@/oss`-provided or already +package-provided equivalents): `@/oss/state/{project,workspace,entities,app}`, `@/oss/lib/Types`, +`@/oss/lib/api`, `@/oss/components/InfiniteVirtualTable`, generic helpers. + +#### WP-4 STATUS (2026-06-10) — leaves done; atom move BLOCKED on a prerequisite + +Landed + green (oss tsc steady 588 throughout): WP-4 unblocker (promote metricUtils → +`@agenta/shared/metrics`, EvaluationStatus → `@agenta/entities/evaluationRun`, SnakeToCamelCaseKeys +→ `@agenta/shared/types`), **4a** (buildRunIndex + evaluationKind → `@agenta/evaluations/core`), +**4b** (active eval services → `@agenta/evaluations/services`), **4c+4d** (usePreviewEvaluations → +`@agenta/evaluations/hooks`; evaluationRuns deduped). + +**4e (atom move) is BLOCKED.** Verified: ~18 of the `EvalRunDetails/atoms` couple to OSS entity-state +(`@/oss/state/entities/{testcase,testset,shared}`), which is a **divergent parallel implementation of +the existing `@agenta/entities` molecules**. Promoting it cascades into a **14–18 day, ~331-file, +app-wide entity-layer re-platform with a tsc-invisible silent-regression risk** (flat vs nested +testcase data). That is its own initiative — see +[entity-state-consolidation-plan.md](./entity-state-consolidation-plan.md). + +**Two ways to unblock 4e (decide when resuming):** +1. **Injection seams** (recommended to finish the eval migration in isolation): the eval atoms receive + testcase/testset/References/workspace data injected from the OSS `-ui` provider (the DoD pattern); + OSS entity layer untouched. Moves 4e safely without the consolidation. +2. **Entity-state consolidation first** (the broader platform goal): execute the C1–C7 plan in the + consolidation doc (human-in-the-loop, QA-gated), then 4e is a clean re-point. + +4f–4l (state, ETL UI, view re-point, EvaluationRunsTablePOC, delete, parity) all follow 4e and are +unchanged. The irreversible deletions (4k / consolidation C7) remain gated on manual parity QA. + +### WP-5 — Rename `annotation`→`annotations`, `annotation-ui`→`annotations-ui` (optional/last) +- Cosmetic alignment with `evaluations`/`evaluations-ui`. Pure rename + re-export shims, no + logic. Do last to avoid churn during WP-1..4. + +--- + +## 6. Genuine gaps (the only places new code is allowed) + +Quantify during WP-1/WP-4; if a capability exists in neither annotation nor a clean OSS form, +it's a gap. Known candidates (verify, don't assume): + +- **ETL filtering is NOT a gap — it's an OSS-ahead feature to MOVE** (WP-3.5), not rebuild. + OSS `EvalRunDetails/etl` (filter bar, scenario filter state, column resolution) is the + source; annotation has none. Move it into `evaluations`/`evaluations-ui`. +- **Auto/invocation specifics** the annotation engine never needed: the auto-eval run loop, + invocation-step columns, run-level metric *aggregates* (annotation is human/per-scenario). + `runMetrics.ts` (13 atoms, temporal + run-level) is the prime suspect for eval-only logic. +- **`buildRunIndex`** (OSS `lib/evaluations`) vs `etl/resolveMappings`/`groupRunColumns`: + overlapping column resolution. Determine if `buildRunIndex` is a true gap or a thin + pre-grouping layer collapsible into the `evaluations` ETL. (Earlier investigation said "no + equiv"; the `etl` evidence suggests otherwise — re-verify during WP-3.5.) + +Anything found here gets a one-line gap entry + a focused, tested addition in `evaluations` — +NOT a reimplementation of something that already exists. + +--- + +## 7. Zero OSS residue — cleanup ledger & gate + +After the migration, the only eval code allowed in `web/oss` / `web/ee` is **route handlers** +(`pages/...`) and **`-ui` providers** that supply inputs (like `AnnotationUIProvider`). +Everything below MUST be deleted (moved into packages), each in the WP that owns its +capability. This ledger is the checklist; do not mark the migration done until every row is +`DELETED` and §7.2 returns empty. + +### 7.1 Cleanup ledger (OSS paths that must be gone) + +**Services (data layer) — `web/oss/src/services/`** +- [ ] `evaluations/results/` → `@agenta/entities/evaluationRun` (done: Fern api) → **delete shell** (WP-4) +- [ ] `evaluations/scenarios/` → `evaluations`/entities → **delete shell** (WP-4) +- [ ] `evaluations/invocations/` → `evaluations`/entities → **delete shell** (WP-4) +- [ ] `evaluations/runShape/` → audit → `evaluations` controller → **delete** (WP-4) +- [ ] `evaluationRuns/` (run-config builder) → `@agenta/evaluations` (`buildRunConfig`) → **delete** (WP-4) +- [ ] `evaluations/api/` (legacy bridge: `GET /evaluations`, `POST /simple/evaluations/`, `_Evaluation`) → **terminal WP**, gated on legacy auto-eval UI replacement; tracked, NOT silently left +- [ ] `onlineEvaluations/` → **terminal WP**, gated on online-eval engine adoption; tracked, NOT silently left + +**Utils / libs / hooks — `web/oss/src/lib/`** +- [ ] `evaluations/` (`buildRunIndex`, `legacy`, `metricUtils` callers) + `evaluations/utils/` (`metrics`, `evaluationKind`) → `@agenta/evaluations` (incl. the ETL home) → **delete** (WP-1/WP-3.5/WP-4; resolve `buildRunIndex` vs ETL per §6) +- [ ] `hooks/usePreviewEvaluations/` (+ `assets/`, `states/`) → `@agenta/evaluations` run hub → **delete** (WP-3/WP-4) +- [ ] `hooks/useEvaluationRunMetrics/` → `@agenta/evaluations` metrics → **delete** (WP-1/WP-4) +- [ ] `evalRunner/`, `evaluators/` → audit; eval-data parts → packages, evaluator defs already in `entities/workflow` → **delete data-layer parts** (WP-4) + +**ETL feature (OSS-ahead; source of truth for filtering) — `web/oss/src/components/EvalRunDetails/etl/`** +- [ ] `EvalRunDetails/etl/` state+hooks (`scenarioFilterState`, `useScenarioFilter`, `useHydrateScenarios`, `useEtlColumns`, `useCellMaterialization`, `useScopeChangeEviction`, `columnValueTypes`) → `@agenta/evaluations` → **delete** (WP-3.5) +- [ ] `EvalRunDetails/etl/` UI (`ScenarioFilterBar`, `EtlColumnHeader`, `cells/EtlResolvedCell`) → `@agenta/evaluations-ui` → **delete** (WP-3.5) +- [ ] `@agenta/entities/evaluationRun/etl` headless primitives → **moved to `@agenta/evaluations`**; remove the `./evaluationRun/etl` subpath export from `entities` once consumers re-point (WP-3.5) + +**Data-layer atoms / state — `web/oss/src/components/` & `state/`** +- [ ] `EvalRunDetails/atoms/` (incl. `mutations/`, `runMetrics/`, `table/`) — the ~38-atom engine → `@agenta/evaluations` → **delete** (WP-4) +- [ ] `EvalRunDetails/state/`, `EvalRunDetails/hooks/`, `EvalRunDetails2/hooks/` → packages → **delete** (WP-4) +- [ ] `EvaluationRunsTablePOC/atoms/`, `EvaluationRunsTablePOC/hooks/` → `@agenta/evaluations`(+`-ui`) → **delete** (WP-3/WP-4) +- [ ] `Evaluations/atoms/` (e.g. `runMetrics` re-export) → packages → **delete** (WP-4) +- [ ] `pages/evaluations/NewEvaluation/state/` (run-creation state) → `@agenta/evaluations` → **delete** (WP-4) +- [ ] `state/evaluator/` → confirm superseded by `entities/workflow` → **delete if dup** (WP-4) + +> Presentational, app-specific components (e.g. EmptyState\*) may remain in OSS — they are not +> services/utils/data-layer. Views with embedded data logic (`EvalRunDetails`, +> `EvaluationRunsTablePOC`) move to `evaluations-ui`; only their route wrappers stay. + +### 7.2 Verification gate (must pass at final DoD — run with a backend-less grep) + +Run from `web/`. Each must return **no output** (except paths on the explicitly-tracked +terminal list — legacy bridge + onlineEvaluations — until their terminal WPs land): + +```bash +# 1. No eval HTTP calls left in OSS/EE (axios to eval endpoints) +grep -rnE "axios\.(get|post|patch|delete)\(.*/(evaluations|simple/evaluations)" oss/src ee/src | grep -v node_modules + +# 2. No eval service dirs left +find oss/src/services -type d | grep -iE "eval" + +# 3. No eval data-layer atom dirs left +find oss/src/components -type d | grep -iE "EvalRunDetails/atoms|EvaluationRunsTablePOC/atoms|Evaluations/atoms" + +# 4. No eval data hooks/utils left +find oss/src/lib -type d | grep -iE "usePreviewEvaluations|useEvaluationRunMetrics|lib/evaluations" + +# 5. No OSS-side eval ETL left (moved to @agenta/evaluations + evaluations-ui) +find oss/src/components -type d | grep -iE "EvalRunDetails/etl" + +# 6. No jotai atoms defined in remaining OSS eval code (should be 0) +grep -rlE "atom\(|atomFamily\(|atomWithQuery\(|atomWithMutation\(" oss/src/components/EvalRunDetails oss/src/components/EvaluationRunsTablePOC 2>/dev/null | grep -v node_modules +``` + +A non-empty result that is NOT on the tracked-terminal list = the migration is **not done**. +The terminal list (legacy bridge, onlineEvaluations) must have its own filed deletion WPs so +it is never "forgotten" — track them in §10 Open until closed. + +## 8. Testing & regression methodology + +**Hard rule — test the SHIPPED atoms, against the REAL API, never a replica.** Every WP that +moves state/logic ships a headless integration test that: +1. **Imports and exercises the exact shipped surface** being moved — the real molecule + selectors, the real controller atoms/actions, the real store atoms, the real api functions. + The test must NOT re-derive, re-implement, or hand-roll the logic it's verifying. If you + delete the package code, the test must fail to compile — that's the proof it's testing the + real thing. +2. **Runs against the real backend** (gated on `AGENTA_API_URL`+`AGENTA_AUTH_KEY`, ephemeral + account; pattern: `evaluationRun.integration.test.ts`). Setup MAY seed data via the raw Fern + client (entities can't depend on `evaluations`), but **all assertions go through the shipped + package surface**, not the raw client. +3. **Covers worker-computed data** (metrics) via the read-only real-project smoke + (`parseExistingRuns.integration.test.ts`) — it can't be produced in the ephemeral harness. + +Anti-pattern that is explicitly banned (it caused this migration's mapping-kind bug): a test +that constructs its own payload/logic (e.g. hand-built `mappings:[]`) instead of calling the +shipped builder/selector — it passes against broken code and proves nothing. + +**Per-WP integration coverage (the shipped surface each WP's test must drive):** + +| WP | Shipped surface under test (real atoms) | Seed | Worker-data | +|---|---|---|---| +| WP-0 | `evaluationScenario` molecule (api + selectors) | create run+scenario via Fern | — | +| WP-1 | `evaluations` session controller (scenario nav/status/metrics/`evaluatorColumnDefs`) + annotation wrapper | populated run | real-project smoke | +| WP-2 | `evaluations` metric/schema fns (`getMetricFieldsFromEvaluator`, `getOutputsSchema`, …) | run with evaluator steps | real-project smoke | +| WP-3 | `evaluations` run-list store (list query, filters, search, windowing) | runs/queues | — | +| WP-3.5 | `evaluations` ETL — hydrate real scenarios + apply a real `rowPredicateFilter`/`filterSchema` | populated run | real-project smoke | +| WP-4 | parity: package-driven derived data == OSS baseline, for the same run id | real runs | real-project smoke | + +- **Parity tests (WP-4):** assert the package-driven view produces the same rows/columns/ + metric values as the OSS baseline for the same run id (snapshot the derived data, not pixels). +- **Manual UI matrix:** the §4 routes, for both annotation (keep-green) and eval (parity) + flows. Required before any OSS deletion. +- **Gating reminder:** integration tests SKIP (read green) without env — never treat a skipped + run as a pass. Run with the backend explicitly. A WP's "tests green" gate means *ran with a + backend and passed*, not *skipped*. + +--- + +## 9. Definition of done (whole migration) + +- One evaluation engine in `evaluations`/`evaluations-ui`; `annotations`/`annotations-ui` are + the queue delta on top, depending on it. +- `@agenta/annotation` no longer contains generic eval logic. +- OSS owns only route handlers + `-ui` providers for eval. **Zero OSS residue:** the §7 + cleanup ledger is fully checked off and the §7.2 verification commands return empty (no eval + services, no eval data-layer atoms, no eval data utils/hooks in `web/oss`/`web/ee`) — save + the explicitly-tracked terminal items, which must each have a filed deletion WP, not be left + silently. +- Human-eval and annotation-queue are presets over the same engine (unblocks replacing human + evals with annotation queues). +- All regression gates green; annotation never regressed. +- **The §11 known-bugs ledger is fully resolved** — every entry fixed (or explicitly waived + with the owner's sign-off). The migration is NOT done with an open §11 bug. + +--- + +## 10. Decisions locked (from review) vs open + +**Locked:** extract from annotation (source of truth for the session/scenario/metrics engine) +with OSS-parity gating before deletion; `entities` is the entity-definitions home; ONE generic +configurable table moved (not rewritten) from `AnnotationQueuesView`. + +**Reversed 2026-06-09:** the eval-run **ETL moves to `evaluations`** (was "stays in +entities"). The ETL filtering is a feature where **OSS is ahead of annotation** (annotation +has none), so it's extracted from OSS `EvalRunDetails/etl` into `evaluations`/`evaluations-ui` +(WP-3.5), and `entities` keeps only entity definitions. + +**Open (decide in-flight, narrowly):** exact home of `markCompleted`/completion + queue +metadata (§3.1 judgment calls); whether `annotation`→`annotations` rename happens now or later +(WP-5); the `buildRunIndex` vs `etl` gap resolution (§6). + +--- + +## 11. Known bugs / coverage gaps to fix before DoD + +Bugs and migration-introduced test-coverage gaps that must be resolved before §9 DoD. Each is +either a real user-facing defect (note the origin) or a test dropped/disabled by a move. Do NOT +close the migration with an open entry here. + +### 11.1 Batch "add all matching to queue" ignores the observability time window (pre-existing) + +- **Discovered:** 2026-06-09, during WP-1 manual QA. **Origin:** pre-existing OSS observability + code — **NOT** a WP-1/migration regression (the batch-add scan path is untouched by the + migration commits; confirmed via `git diff`). +- **Symptom:** with an observability filter + "Last 7 days" range active, "add all matching to + queue" adds up to the cap (1000 / `DEFAULT_MAX_ITEMS`, hobby tier) including traces far older + than the window ("some look invalid"), even when the project has far fewer than 1000 traces + in the last 7 days. +- **Root cause (traced):** the two trace-query paths shape the time window differently. The main + table builds an explicit `windowing: {oldest, newest}` object and the cursor loop is bounded by + it. The batch-add **scan** path passes `oldest`/`newest` as **flat top-level params** + (`buildTraceQueryParams` → `params.oldest` from `sort`) and pages **backward via the `newest` + cursor** through `createAdaptiveTracePageFetcher` → `executeTraceQuery`. The lower-bound + termination (`nextCursor <= params.oldest` → stop) is wired **only in the `has_annotation` + branch** of `executeTraceQuery` (`oss/src/state/newObservability/atoms/queryHelpers.ts` ~L304–308); + on the **plain-filter path** nothing stops backward paging at `params.oldest`, so it walks all + history to the cap. +- **Files:** `oss/src/components/pages/observability/components/ObservabilityHeader/useBatchAddTracesToQueue.tsx`, + `oss/src/state/newObservability/etl/adaptiveTracePageFetcher.ts`, + `oss/src/state/newObservability/atoms/queryHelpers.ts` (`executeTraceQuery`), and + `fetchAllPreviewTracesWithMeta` (confirm it forwards `oldest` to the backend — last piece to verify). +- **Fix direction:** apply the `params.oldest` lower-bound termination on the plain-filter scan + branch too (mirror the has-annotation branch), or have the scan reuse the main table's + `windowing` shape so both paths bound identically. Fix on its **own branch**, not mixed into a + migration WP. +- **UPDATE 2026-06-11 — original root cause FALSIFIED by code inspection.** The plain-filter + branch DOES have the lower-bound cursor termination (`minVal <= lowerBound → nextCursor = + undefined`, in `executeTraceQuery`'s tail) and it has existed since 2025-12-19 (`80b99892f4`) — + pre-dating the Jun 9 repro. The full chain is verified correct in current code: scan + `params.oldest` (from sort) → `createAdaptiveTracePageFetcher` preserves it → + `fetchAllPreviewTracesWithMeta` → `buildWindowAndFilter` maps flat `oldest`/`newest` → + Fern `windowing.{oldest,newest}` → backend-bounded query; cursor pages stop at the lower bound. + Candidate explanations for the observed over-add: (a) the legacy pre-Fern transport in the code + running at repro time (replaced by the AGE-3788 Fern path now merged via v0.103.1) handled the + flat window params differently; (b) accumulation across multiple scan runs (one screenshot + showed a queue at 10,647 items — far above one run's 1,000 cap); (c) "invalid-looking" rows + being unresolvable-ref scenarios rather than out-of-window traces. +- **Status: ✅ CLOSED 2026-06-11.** Re-repro on the current stack captured the actual + `/traces/query` payloads: `windowing.oldest` present, cursor descending — transport correct. + The "over-add" was real data: the seeded eval runs generated thousands of in-window + invocation traces (one queue holds 11,647 items), so 1,000+ matches were legitimate; user + concurred ("maybe that was my mistake"). Related but separate: the queues-table ordering + complaint from the same QA was a REAL bug (id-DESC paging vs created_at display) — fixed + end-to-end in commit `43523a6695` (backend created_at windowing + tie-break fix + FE + windowing threading), verified live by the user. + +### 11.2 Combined paginatedStore+molecule leak test dropped in WP-3.5a (coverage gap) + +- **Discovered/introduced:** WP-3.5a (moving `evaluationRun/etl` → `@agenta/evaluations`). +- **What:** the entities longrun leak test `runLoop.combinedLeak.test.ts` had a "Combined leak: + paginatedStore + molecule layer" block that depended on `evaluationRun/etl/cacheDiagnostics`. + Keeping it in entities after the ETL moved would force an `entities → evaluations` import cycle + (forbidden). It was **removed from entities and NOT relocated** to evaluations — relocating it + faithfully needs a raw `node --import tsx` leak harness that crashes on the entities barrels' + transitive `@agenta/ui` CSS imports, which would require 3+ new UI-free entities subpaths + + a react-query dep + a CSS-stub loader — beyond the WP-3.5a "≤2 API gaps" guard. The generic + `instrumentedAtomFamily` leak block stays in entities and still runs. +- **Net:** lost leak-regression coverage for the paginatedStore + molecule combination. +- **Fix direction:** add a UI-free `@agenta/evaluations`-side leak harness (or narrow UI-free + entities subpaths) that exercises the combined paginatedStore + molecule path. Its own task. +- **Status:** ✅ RESOLVED — restored as `web/packages/agenta-evaluations/tests/unit/combinedLeak.test.ts` + (vitest, runs in the standard unit suite): 12-iteration paginatedStore+molecule pipeline asserting + atom-family params and TanStack cache entries drain to baseline after per-iteration teardown + (heap-slope budget additionally asserted when `--expose-gc` is available). + +### 11.3 Pre-existing latent runtime bugs in EvalRunDetails, surfaced by WP-4e-2a (NOT migration regressions) + +WP-4e-2a type-checked the EvalRunDetails atom layer (which OSS ships with ~45 tsc errors the bundler +ignores). Five latent runtime bugs were **typed-as-is, NOT fixed** (behavior preserved). They predate +the migration; triage/fix separately (likely with the EvalRunDetails parity QA). For QA: +1. **`atoms/metrics.ts` `applyAggregatesToRaw`** — referenced in `buildRunLevelMetricData`, defined/ + imported nowhere → `ReferenceError` whenever run-level metric data is built. +2. **`atoms/runMetrics.ts` `metricProcessor`** — referenced at the run-level-gap branch (~L880) but the + in-scope processor is named `processor` → `ReferenceError` when `shouldMarkRunLevelGap` is true. +3. **`utils/buildSkeletonColumns.ts`** — the "outputs" group call passes 5 positional args (omits + `stepType`) → at runtime `order: NaN`, `stepType: 200` for the outputs skeleton group. +4. **`utils/buildPreviewColumns.tsx`** — `column.kind === "input"` is always false (kind has no + `"input"`; likely meant `stepType`/`columnType`) → width always falls through to `metric`. +5. **`atoms/runMetrics.ts` (~L1223/1352)** — `loadable.data` is the full `AtomWithQueryResult` wrapper, + not the unwrapped `RunLevelStatsMap` (elsewhere at ~L1050 it's correctly unwrapped via `"data" in`). + Possible run-level-stats unwrap inconsistency. +- **Status:** OPEN — pre-existing; flag to eval owners; verify during EvalRunDetails parity QA. + +### 11.4 `no-explicit-any` file-disables on relocated eval atoms (WP-4e-2b debt) + +- **Introduced:** WP-4e-2b (relocating EvalRunDetails atoms → `@agenta/evaluations/state/evalRun`). +- **What:** 27 relocated files carry a file-level `/* eslint-disable @typescript-eslint/no-explicit-any */` + header — ~294 load-bearing dynamic-backend-shape `any`s. Done deliberately to keep the move + byte-identical (faithful) on a keep-green parity layer rather than risk a 294-site retype; matches + existing package precedent (`buildRunIndex`, `usePreviewEvaluations/types`). +- **Fix direction:** tighten to precise/`unknown` types incrementally, file-by-file, after the + EvalRunDetails parity QA confirms behavior. +- **Status:** OPEN — debt, not a blocker; incremental cleanup. +- **WP-4h extension:** the 3 relocated `MetricDetails` files (`MetricDetailsPreviewPopover.tsx`, + `MetricDetailsPopover/assets/{ResponsiveMetricChart,utils}.tsx|ts`) carry the same file-level + disable for the same reason (dynamic backend stat blobs as `Record`). Same fix + direction. + +### 11.6 Eval render trees still on the OSS InfiniteVirtualTable copy (follow-up WP) + +- **Discovered:** 2026-06-11 components/hooks consolidation audit. The `EvaluationRunsTablePOC` + and `EvalRunDetails` RENDER TREES still consume the OSS `@/oss/components/InfiniteVirtualTable` + copy (shell, export hook, columnVisibility base, scroll-container context). The `@agenta/ui` + copy has diverged **ahead** (row-height, type-chips, grouped trees — 300+ diff lines on the + shell). Partial re-points would split jotai context/atom identity between the two copies, so + the switch must be done per render-tree in one pass (POC tree, then EvalRunDetails tree), with + behavioral QA. Self-contained leaf pieces were already re-pointed (FiltersPopoverTrigger, + TableTabsConfig). Its own WP; pairs naturally with 4h (view move to evaluations-ui). +- **Status:** ✅ RESOLVED (slice 1 `c2a420bd02` switched the eval trees; slice 2 `c7baf6d2e8` + re-pointed the remaining consumers — Testsets/Testcases/Playground/AddToTestsetDrawer trees + + the testcase/testset/shared entity-state paginatedStores' table-infra imports — and **DELETED + the entire OSS `components/InfiniteVirtualTable/` copy** (55 files / ~9,928 LOC). The entity-state + table-infra imports were independent of the molecule consolidation, so deletion did NOT need it. + oss tsc 480→471. Whole app now uses one table component (`@agenta/ui/table`). + +### 11.5 `useScenarioLiveUpdates` + `evaluationPreviewTableStore` not yet moved (WP-4g deferral) + +- **Discovered:** WP-4g. `EvalRunDetails/etl/useScenarioLiveUpdates.ts` (eval data logic) is still in + OSS because it imports `EvalRunDetails/evaluationPreviewTableStore.ts`, which is `@/oss`-coupled via + `@/oss/components/InfiniteVirtualTable`. +- **Fix direction:** migrate `evaluationPreviewTableStore` onto `@agenta/ui/table`'s + `createInfiniteTableStore`/`useInfiniteTablePagination` (the package equivalents `EvaluationListView` + already uses) → `@agenta/evaluations`, then `useScenarioLiveUpdates` moves cleanly. Its own small WP. +- **Status:** ✅ RESOLVED (WP-4g-2). The OSS `InfiniteVirtualTable` turned out to be an API-compatible + STALE COPY of `@agenta/ui/table` (not divergent) — both files moved with a simple re-point. KEY + finding: the table infra is re-pointable; only the ENTITY-STATE (testcase/testset) is genuinely + divergent (the consolidation doc). oss tsc dropped 522→487 (index-sig fix unmasked+fixed ~35 latent). + +> **Note:** the OSS tsc baseline dropped from **588 → 522** at WP-4e-2a (the ~45 eval-atom errors + +> ~21 root-caused side effects fixed). **All subsequent "oss tsc steady" gates use 522, not 588.** + +## 12. WP-4h — eval VIEW layer → `@agenta/evaluations-ui` (classified cascade + phased plan) + +User explicitly chose the full view-layer move (2026-06-11) over the cheaper in-OSS tidy. +The data goal is already done bar one service (`onlineEvaluations` start/stop), so WP-4h is +purely a **presentation relocation**: the three OSS dirs `Evaluations/` (9 files, the +`MetricDetailsPopover`), `EvaluationRunsTablePOC/` (37 files, run-list), `EvalRunDetails/` +(113 files, run-details) → one `@agenta/evaluations-ui` tree as siblings +`{RunsTable, RunDetails, MetricDetails}` (drop the `POC` suffix; fold the misnamed +`Evaluations/`). + +### 12.1 The ~90 `@/oss` couplings, classified + +Destination `@agenta/evaluations-ui` already exists (nearly empty) and the seam registry +(`evalRunInjection.ts` / `registerEvalRunInjections`) from WP-4e is reusable. + +| Bucket | Count | Disposition | +|---|---|---| +| Internal cross-refs (the 3 dirs) | ~9 | become relative on move — free | +| Pure utils (`lib/helpers/*`, `runMetrics/formatters`, `onboarding`) | ~8 | move → `@agenta/shared` | +| Generic UI (`GenericDrawer`, `EnhancedUIs/Drawer`, `SimpleSharedEditor`, `EmptyComponent`, `QuickDateRangePicker`, `lib/atoms/virtualTable`, `CustomTreeComponent`, `DrillInView`) | ~10 | move → `@agenta/ui` **or** seam if self-coupled | +| OSS app state/hooks (`state/{project,app,appState,workspace,session,url,workflow,queries}`, `hooks/{useURL,useProjectPermissions,useQuery,useAppId}`, `lib/hooks/{useBreadcrumbs,useAnnotations}`) | ~25 | **inject via seam** (extend `registerEvalRunInjections`) | +| `state/entities/{testset,testcase}` | 3 | **seam** — do NOT drag in the entity consolidation (the 14–18d initiative) | +| `services/{onlineEvaluations,annotations}/api` | 5 | move the eval-exclusive ones → `@agenta/evaluations`; seam annotations if shared | +| **References subsystem** | 23 | ⚠️ **shared** — 3,478 LOC / 20 files / **8 non-eval consumers** → **seam, do not relocate** | +| **onlineEvaluation pages** | ~12 | 2,863 LOC / 20 files, eval-specific but cascades → **seam** (inject EmptyStates/FiltersPreview/EvaluatorDetails) | +| `SharedDrawers/AnnotateDrawer/*`, `SharedGenerationResultUtils` | ~7 | shared → seam or move-to-package | + +### 12.1b Coupling re-bucket (post-canary, the 77 remaining) + seam-count finding + +After the canary, the 2 remaining dirs import **77 distinct `@/oss` paths**. Facade check: only +`copyToClipboard` re-exports a package. Package-equivalent check on the app-state/util symbols: +only `projectIdAtom` (→`@agenta/shared/state`) and `isUuid` (→`@agenta/evaluations`) already exist. +**Everything else (~70) genuinely needs a seam.** Buckets: A internal self-refs (7, move together), +B References (14, seam), C onlineEvaluation/pages (15, seam), D OSS app-state (12, seam), E OSS hooks +(6, seam), F utils/lib (9, seam — moving to shared = app-wide churn), G generic UI (12, seam), H misc (2). + +**Cost finding surfaced to the user (2026-06-11):** ~70 injection seams, 18 of which (D+E) are +non-eval app-context (routing/project/breadcrumbs/onboarding) — i.e. the machinery that makes +RunDetails *a page*, not a reusable component. Flagged that seaming 70 app-level deps to package-ify a +page-view is brittle architecture orthogonal to the (already-complete) data goal. **User chose the full +~70-seam relocation anyway.** Proceeding faithfully; recording the cost here as the rationale of record. + +### 12.1c Seam architecture — three channels + +Atoms alone can't carry this (hooks/components aren't atoms). Three injection channels: +1. **Injected atoms** (buckets D state, H `virtualTable`): extend `registerEvalRunInjections` with + `injected*Atom`s set by the OSS provider — the proven WP-4e mechanism. (`projectIdAtom`/`isUuid` + are plain re-points, not seams.) +2. **Injected hook/fn registry** (bucket E hooks + bucket F pure utils that stay in OSS): a module-level + registry of function implementations the OSS provider populates at boot; package code calls the + registered impl (`useURL`, `useAppId`, `useProjectPermissions`, `useBreadcrumbsEffect`, + `getProjectValues`, `getUniquePartOfId`, `formatDate24`, `buildRevisionsQueryParam`). +3. **Injected component slots** (buckets B References, C onlineEvaluation, G generic UI, AnnotateDrawer): + a React context (`EvalViewHostProvider` in evaluations-ui) supplying OSS-owned components as slots; + package views render `slots.ReferenceTag` etc. OSS provides the real components at the route shell. + +### 12.2 Locked decision: SEAM the shared subsystems, MOVE the eval-exclusive code + +"Full move" is only completable if References / onlineEvaluation / AnnotateDrawer are +**injected from OSS**, not physically relocated. References especially is a shared +annotation subsystem with **8 non-eval consumers** — relocating it is a separate, +unbounded migration and out of scope. This mirrors the WP-4e discipline (seam the +`@/oss` wall rather than drag in the consolidation). Physical relocation of References can +be an additive follow-up. End-state: eval VIEW layer is fully package-resident; the +genuinely-shared subsystems stay in OSS behind seams. + +### 12.3 Phased execution (each phase: build+lint+integration-test, STOP-on-cascade) + +- **4h-0 — data tail.** Move `startSimpleEvaluation`/`stopSimpleEvaluation` + `QueryWindowingPayload` + → `@agenta/evaluations`; delete `@/oss/services/onlineEvaluations`. 3 importers. On-goal, small. +- **4h-1 — utils/UI base.** Move pure utils → `@agenta/shared`, generic UI → `@agenta/ui` + (or seam the self-coupled ones). tsc-catchable, no behavioral change. +- **4h-2 — seam scaffolding.** Extend `registerEvalRunInjections` with the view-layer seams: + OSS app state/hooks, `state/entities/{testset,testcase}`, References renderers, + onlineEvaluation components, AnnotateDrawer. OSS `-ui` provider registers the real sources. +- **4h-3 — relocate `MetricDetails`** (`Evaluations/`, only 1 `@/oss` coupling) → `evaluations-ui`. Canary. + ✅ DONE — moved 9 files → `evaluations-ui/src/components/MetricDetails/`, fixed 7 latent strict-null + type errors + added `usehooks-ts`/`jotai-scheduler` deps, re-pointed 9 OSS consumers to the barrel, + deleted OSS `components/Evaluations/`. evaluations-ui check green; oss tsc 471→464 (latent errors left + with the files); behavioral QA pending (annotations queue metric popover + run-details metric cells). +- **4h-2 — seam infra.** ✅ DONE (`554954b14f`): `EvalViewHostProvider`/`useHostComponent`/`useHostHook` + (component+hook channel) in evaluations-ui; the atom channel (`registerEvalRunInjections`) already existed. + 4h-4 added a 4th channel: `fnRegistry` (`registerEvalViewFns`/`getEvalViewFns`) for non-React functions + consumed outside React (navigation builders, url helpers). +- **4h-4 — relocate `RunsTable`** (`EvaluationRunsTablePOC` → `RunsTable`, drop POC) → `evaluations-ui`. + ✅ DONE (`329aa640db`): all 37 files moved as one closed cluster (tree too interconnected to split — + `useEvaluationRunsColumns` pulls every cell/header). ~20 atom seams + component/hook host + fnRegistry + added; OSS seam boundary `EvalRunsViewHost.tsx` mounted at `EvaluationsView` + the app overview page. + oss tsc 464→454 (10 latent strict-null fixed). Verified: no package→`@/oss` leak; host wraps both render + sites. References/onlineEvaluation/AnnotateDrawer stayed in OSS behind seams (§12.2). Behavioral QA pending. +- **4h-5 — relocate `RunDetails`** (`EvalRunDetails`, 113 files) → `evaluations-ui`. Largest; behavioral QA. + Reuses all 4 seam channels; the run-details OSS provider `useRegisterEvalRunInjections` (mounted at the + run-details `Page.tsx`) extends to register the new seams + a host provider at the route shell. + **⏳ NOT YET EXECUTED — fully analyzed, atomic, resumable.** Two independent agents confirmed: the 113 + files form a clean 10-layer DAG (no cycles), but leaf-first incremental moves generate ~40 barrel + re-points that immediately revert, whereas a **whole-tree move preserves all 184 intra-tree relative + imports for free** (destination mirrors source) — so the correct unit is ONE atomic slice (fix only the + ~42 external couplings + 5 absolute self-paths + 12 self-barrel imports + 4 reverse-deps + host + boundary). This is a single ~150-edit change that cannot bank partial progress; it exceeded a single + subagent budget twice (both reverted to green). **Complete file-by-file execution recipe** (deps to add, + exact coupling→channel map per file, the AppGlobalWrappers global-mount runtime-throw trap, the 6 route + pages + EE, Rules-of-Hooks early-return files) is in the WP-4h-5 agent reports — resume as a dedicated + pass. Baseline to resume from: HEAD with RunsTable done, oss tsc 454. + +#### 4h-5 execution recipe (banked — pure mechanical, no discovery left) +1. **Deps** → `evaluations-ui/package.json`: `@agenta/sdk` (workspace), `fast-deep-equal ^3.1.3`, + `jotai-immer ^0.4.1`, `recharts ^2.13.0`; peerDep `next >=14.0.0`; then `pnpm install`. +2. **Move:** `git mv EvalRunDetails/* → evaluations-ui/src/components/RunDetails/*` EXCEPT host-boundary + files `EvalResultsOnboarding.tsx`, `test.tsx`, `hooks/useRegisterEvalRunInjections.ts` (first two → + `oss/components/pages/evaluations/`, third folds into the new host). `git mv` sequentially (index.lock). +3. **5 absolute self-paths → relative:** `Table.tsx`, `utils/chatMessages.ts`, `OverviewView/utils/metrics.ts`, + `OverviewView/components/{MetricComparisonCard,BaseRunMetricsSection}.tsx`. +4. **12 self-barrel imports → relative** (files importing `@agenta/evaluations-ui` from within it): + `format3Sig`→`components/MetricDetails/MetricDetailsPopover`; `MetricDetailsPreviewPopover`→its path; + `invalidateEvaluationRunsTableAtom`→`components/RunsTable/atoms/tableStore`; `useEtlColumns`/`ScenarioFilterBar` + →`components/etl/*`. (Table, MetricCell, VirtualizedScenarioTableAnnotateDrawer, FocusDrawer, + PreviewEvalRunHeader, EvaluatorMetricsChart/index, EvaluatorMetricsSpiderChart, RunSummaryCard, + OverviewView/utils/metrics, MetricComparisonCard, ScenarioAnnotationPanel/index, export/columnResolvers.) +5. **~42 couplings → channels** (re-point pkg-equiv: axios/getAgentaApiUrl→`@agenta/shared/api`, + dayjs→`@agenta/shared`, QueryWindowingPayload→`@agenta/evaluations/state`, projectIdAtom→`@agenta/shared/state`; + ~20 `useHostComponent`; ~11 `useHostHook`; ~7 fnRegistry incl. `getProjectValues`/`create|updateAnnotation` + [inject, OSS sigs differ]/`formatDate24`/annotation transforms; atom-channel `navigationRequestAtom`; MOVE + `virtualScenarioTableAnnotateDrawerAtom`→`RunDetails/state/`; const `EVALUATOR_CATEGORY_LABEL_MAP`→evaluations). + **Hoist host calls above early returns** in `InvocationTraceSummary`, `EvalDrawerDataSection`. +6. **Host:** `oss/components/pages/evaluations/EvalRunDetailsViewHost.tsx` (mirror `EvalRunsViewHost.tsx`) → + register atom+fn seams + ``; re-point all **6 route pages** + (oss+ee × {results, single_model_test} × {project, app}). +7. **GLOBAL-MOUNT TRAP (tsc-invisible runtime throw):** `AppGlobalWrappers/index.tsx` mounts + `EvalRunFocusDrawerPreview` (→`FocusDrawer`→`GenericDrawer` host slot) GLOBALLY — wrap it in an + `EvalViewHostProvider` too, or it throws at mount. + **RESOLVED + then DE-GLOBALIZED (`4081b1518f`):** the global mount made eval seam-registration run + on every page, so three seam bugs (referenceColors `e52578ce79`; jotai function-as-updater + `901195beb6`; read-only `atom(()=>{})` onboarding atoms `7eb5fc6c3d`) crashed non-eval pages + (e.g. testsets) instead of being contained to eval pages. The focus drawer is only opened from + run-details (the scenario table sets `focusScenarioId`), so the mount moved into the run-details + page tree (`EvalRunDetailsTestPage`, already host-wrapped) and was removed from `AppGlobalWrappers`. + Non-eval pages now load zero eval-view machinery. **Lesson:** don't mount a package view's host in + the app-global layer — mount it only on the surfaces that render that view, or an eval bug becomes + an everywhere bug. +8. **4 reverse-dep re-points → barrel:** `state/url/focusDrawer.ts`, `References/cells/QueryCells.tsx`, + `AppGlobalWrappers/index.tsx`, `AnnotateCollapseContent/index.tsx`. Delete vestigial + `export * from "@/oss/components/References"` in `OverviewView/components/index.ts`. +9. **Gates:** evaluations-ui `check` green; oss tsc ≤454; eslint touched OSS files. One commit. +- **4h-6 — repoint route shells** (the 6 pages) at `@agenta/evaluations-ui`; OSS keeps only + route shells + the injection-seam provider. Delete the 3 emptied OSS dirs. +- **Gate:** full behavioral QA across run-list (app overview), run-details (results + + single_model_test), annotation queue metric popover, annotate flow. diff --git a/web/ee/next.config.ts b/web/ee/next.config.ts index b70caf84d4..497d545ee4 100644 --- a/web/ee/next.config.ts +++ b/web/ee/next.config.ts @@ -30,6 +30,8 @@ const config = { "@agenta/playground-ui", "@agenta/annotation", "@agenta/annotation-ui", + "@agenta/evaluations", + "@agenta/evaluations-ui", ], }, typescript: { diff --git a/web/ee/package.json b/web/ee/package.json index 85ec3f76bb..35b5cccdb9 100644 --- a/web/ee/package.json +++ b/web/ee/package.json @@ -23,6 +23,8 @@ "@agenta/annotation-ui": "workspace:../packages/agenta-annotation-ui", "@agenta/entities": "workspace:../packages/agenta-entities", "@agenta/entity-ui": "workspace:../packages/agenta-entity-ui", + "@agenta/evaluations": "workspace:../packages/agenta-evaluations", + "@agenta/evaluations-ui": "workspace:../packages/agenta-evaluations-ui", "@agenta/oss": "workspace:../oss", "@agenta/playground": "workspace:../packages/agenta-playground", "@agenta/playground-ui": "workspace:../packages/agenta-playground-ui", diff --git a/web/ee/src/components/pages/settings/AuditLog/components/AuditLogFilters.tsx b/web/ee/src/components/pages/settings/AuditLog/components/AuditLogFilters.tsx index 0a9acaf8ff..912e537597 100644 --- a/web/ee/src/components/pages/settings/AuditLog/components/AuditLogFilters.tsx +++ b/web/ee/src/components/pages/settings/AuditLog/components/AuditLogFilters.tsx @@ -22,7 +22,7 @@ import {Cascader, Input} from "antd" import {useAtom, useSetAtom} from "jotai" import EnhancedButton from "@/oss/components/EnhancedUIs/Button" -import QuickDateRangePicker from "@/oss/components/EvaluationRunsTablePOC/components/filters/QuickDateRangePicker" +import QuickDateRangePicker from "@/oss/components/Filters/QuickDateRangePicker" const HIDDEN_EVENT_TYPE_PREFIXES = ["applications.revisions.", "evaluators.revisions."] const HIDDEN_EVENT_TYPES = ["unknown"] diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx index 1b8082dc53..05753ee6fa 100644 --- a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx @@ -1,6 +1,6 @@ import {useRouter} from "next/router" -import EvalRunDetailsPage from "@/oss/components/EvalRunDetails/test" +import EvalRunDetailsPage from "@/oss/components/pages/evaluations/EvalRunDetailsTestPage" const AppEvaluationResultsPage = () => { const router = useRouter() diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/evaluations/results/[evaluation_id]/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/evaluations/results/[evaluation_id]/index.tsx index 51c38a3009..0336fe2652 100644 --- a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/evaluations/results/[evaluation_id]/index.tsx +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/evaluations/results/[evaluation_id]/index.tsx @@ -1,6 +1,6 @@ import {useRouter} from "next/router" -import EvalRunDetailsPage from "@/oss/components/EvalRunDetails/test" +import EvalRunDetailsPage from "@/oss/components/pages/evaluations/EvalRunDetailsTestPage" const ProjectEvaluationResultsPage = () => { const router = useRouter() diff --git a/web/oss/next.config.ts b/web/oss/next.config.ts index adcb8cd1b2..07bb967fe0 100644 --- a/web/oss/next.config.ts +++ b/web/oss/next.config.ts @@ -67,6 +67,8 @@ const COMMON_CONFIG: NextConfig = { "@agenta/playground-ui", "@agenta/annotation", "@agenta/annotation-ui", + "@agenta/evaluations", + "@agenta/evaluations-ui", // Icon libraries - ensure tree-shaking works for individual icon imports "@phosphor-icons/react", "lucide-react", @@ -84,6 +86,8 @@ const COMMON_CONFIG: NextConfig = { "@agenta/playground-ui", "@agenta/annotation", "@agenta/annotation-ui", + "@agenta/evaluations", + "@agenta/evaluations-ui", ...(!isDevelopment ? [ "rc-util", diff --git a/web/oss/package.json b/web/oss/package.json index 438871afda..3b7b6632a5 100644 --- a/web/oss/package.json +++ b/web/oss/package.json @@ -23,6 +23,8 @@ "@agenta/annotation-ui": "workspace:../packages/agenta-annotation-ui", "@agenta/entities": "workspace:../packages/agenta-entities", "@agenta/entity-ui": "workspace:../packages/agenta-entity-ui", + "@agenta/evaluations": "workspace:../packages/agenta-evaluations", + "@agenta/evaluations-ui": "workspace:../packages/agenta-evaluations-ui", "@agenta/playground": "workspace:../packages/agenta-playground", "@agenta/playground-ui": "workspace:../packages/agenta-playground-ui", "@agenta/sdk": "workspace:../packages/agenta-sdk", diff --git a/web/oss/src/components/AppGlobalWrappers/index.tsx b/web/oss/src/components/AppGlobalWrappers/index.tsx index 00a4ae209c..f34b0f5b79 100644 --- a/web/oss/src/components/AppGlobalWrappers/index.tsx +++ b/web/oss/src/components/AppGlobalWrappers/index.tsx @@ -32,11 +32,6 @@ const TraceDrawer = dynamic( {ssr: false}, ) -const EvalRunFocusDrawerPreview = dynamic( - () => import("@/oss/components/EvalRunDetails/components/EvalRunFocusDrawerMount"), - {ssr: false}, -) - const SelectDeployVariantModalWrapper = dynamic( () => import("@/oss/components/DeploymentsDashboard/modals/SelectDeployVariantModalWrapper"), {ssr: false}, @@ -204,7 +199,6 @@ const AppGlobalWrappers = () => { - diff --git a/web/oss/src/components/DeleteEvaluationModal/DeleteEvaluationModalContent.tsx b/web/oss/src/components/DeleteEvaluationModal/DeleteEvaluationModalContent.tsx index 3862d60bad..1e3db521ee 100644 --- a/web/oss/src/components/DeleteEvaluationModal/DeleteEvaluationModalContent.tsx +++ b/web/oss/src/components/DeleteEvaluationModal/DeleteEvaluationModalContent.tsx @@ -1,13 +1,12 @@ import {useCallback, useEffect, useMemo, useState} from "react" +import {deleteEvaluationRuns} from "@agenta/entities/evaluationRun" +import {clearPreviewRunsCache} from "@agenta/evaluations/hooks" import {message} from "@agenta/ui/app-message" import {Typography} from "antd" import {getDefaultStore} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import axios from "@/oss/lib/api/assets/axiosConfig" -import {clearPreviewRunsCache} from "@/oss/lib/hooks/usePreviewEvaluations/assets/previewRunsRequest" - import type {DeleteEvaluationModalDeletionConfig} from "./types" interface DeleteEvaluationModalContentProps { @@ -20,10 +19,7 @@ interface DeleteEvaluationModalContentProps { const deletePreviewRuns = async (projectId: string | null | undefined, runIds: string[]) => { if (!projectId || runIds.length === 0) return - await axios.delete(`/evaluations/runs/`, { - params: {project_id: projectId}, - data: {run_ids: runIds}, - }) + await deleteEvaluationRuns({projectId, runIds}) } const DeleteEvaluationModalContent = ({ diff --git a/web/oss/src/components/EditEvaluationDrawer/index.tsx b/web/oss/src/components/EditEvaluationDrawer/index.tsx index 512be66a85..e96211501d 100644 --- a/web/oss/src/components/EditEvaluationDrawer/index.tsx +++ b/web/oss/src/components/EditEvaluationDrawer/index.tsx @@ -7,6 +7,13 @@ import { useEnrichedHumanEvaluatorAdapter, type WorkflowRevisionSelectionResult, } from "@agenta/entity-ui/selection" +import {saveEvaluationEditAtom} from "@agenta/evaluations/state/evalRun" +import { + evaluationEvaluatorsByRunQueryAtomFamily, + evaluatorDefinitionByRevisionQueryAtomFamily, +} from "@agenta/evaluations/state/evalRun" +import {evaluationRunQueryAtomFamily} from "@agenta/evaluations/state/evalRun" +import {derivedEvalTypeAtomFamily} from "@agenta/evaluations/state/evalRun" import {VersionBadge} from "@agenta/ui" import {message} from "@agenta/ui/app-message" import {Plus, Trash} from "@phosphor-icons/react" @@ -14,13 +21,6 @@ import {Button, Input, Tag, Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" -import {saveEvaluationEditAtom} from "@/oss/components/EvalRunDetails/atoms/mutations/editEvaluation" -import { - evaluationEvaluatorsByRunQueryAtomFamily, - evaluatorDefinitionByRevisionQueryAtomFamily, -} from "@/oss/components/EvalRunDetails/atoms/table/evaluators" -import {evaluationRunQueryAtomFamily} from "@/oss/components/EvalRunDetails/atoms/table/run" -import {derivedEvalTypeAtomFamily} from "@/oss/components/EvalRunDetails/state/evalType" const {Text} = Typography diff --git a/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts b/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts deleted file mode 100644 index f65f03cc9a..0000000000 --- a/web/oss/src/components/EvalRunDetails/atoms/table/testcases.ts +++ /dev/null @@ -1,144 +0,0 @@ -import {createBatchFetcher, type BatchFetcher} from "@agenta/shared/utils" -import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" -import {atomWithQuery} from "jotai-tanstack-query" - -import axios from "@/oss/lib/api/assets/axiosConfig" -import type {PreviewTestCase} from "@/oss/lib/Types" -import {getProjectValues} from "@/oss/state/project" - -import {resolveTestcaseValueByPath, splitPath} from "../../utils/valueAccess" -import {activePreviewRunIdAtom, effectiveProjectIdAtom} from "../run" - -const testcaseBatcherCache = new Map>() - -const normalizeTestcase = (raw: any): PreviewTestCase | null => { - if (!raw) return null - const id = raw.id ?? raw.testcase_id - if (!id) return null - - const testsetId = - raw.testset_id ?? raw.testsetId ?? raw.set_id ?? raw.setId ?? raw.testsetId ?? "" - const setId = raw.set_id ?? raw.setId ?? testsetId - - return { - ...raw, - id, - testset_id: testsetId, - set_id: setId, - created_at: raw.created_at ?? raw.createdAt ?? "", - updated_at: raw.updated_at ?? raw.updatedAt ?? "", - created_by_id: raw.created_by_id ?? raw.createdById ?? "", - data: raw.data ?? raw.inputs ?? {}, - } -} - -const resolveEffectiveRunId = (get: any, runId?: string | null) => - runId ?? get(activePreviewRunIdAtom) ?? undefined - -export const evaluationTestcaseBatcherFamily = atomFamily(({runId}: {runId?: string | null} = {}) => - atom((get) => { - const {projectId: globalProjectId} = getProjectValues() - const projectId = globalProjectId ?? get(effectiveProjectIdAtom) - const effectiveRunId = resolveEffectiveRunId(get, runId) - if (!projectId) return null - - const cacheKey = `${projectId}:${effectiveRunId ?? "preview"}` - let batcher = testcaseBatcherCache.get(cacheKey) - if (!batcher) { - testcaseBatcherCache.clear() - batcher = createBatchFetcher({ - serializeKey: (key) => key, - batchFn: async (testcaseIds) => { - const uniqueIds = Array.from(new Set(testcaseIds.filter(Boolean))) - if (uniqueIds.length === 0) { - return {} - } - - const response = await axios.post( - `/testcases/query`, - {testcase_ids: uniqueIds}, - { - params: {project_id: projectId}, - }, - ) - - const rows = Array.isArray(response.data?.testcases) - ? response.data.testcases - : [] - - const result: Record = Object.create(null) - rows.forEach((row: any) => { - const normalized = normalizeTestcase(row) - if (normalized?.id) { - result[normalized.id] = normalized - } - }) - - uniqueIds.forEach((id) => { - if (typeof result[id] === "undefined") { - result[id] = null - } - }) - - return result - }, - }) - testcaseBatcherCache.set(cacheKey, batcher) - } - - return batcher - }), -) - -export const evaluationTestcaseBatcherAtom = atom((get) => get(evaluationTestcaseBatcherFamily())) - -export const evaluationTestcaseQueryAtomFamily = atomFamily( - ({testcaseId, runId}: {testcaseId: string; runId?: string | null}) => - atomWithQuery((get) => { - const {projectId: globalProjectId} = getProjectValues() - const projectId = globalProjectId ?? get(effectiveProjectIdAtom) - const effectiveRunId = resolveEffectiveRunId(get, runId) - const batcher = get(evaluationTestcaseBatcherFamily({runId: effectiveRunId})) - - return { - queryKey: ["preview", "evaluation-testcase", effectiveRunId, projectId, testcaseId], - enabled: Boolean(projectId && batcher && testcaseId), - staleTime: 30_000, - gcTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - structuralSharing: true, - queryFn: async () => { - if (!batcher) { - throw new Error("Testcase batcher is not initialised") - } - const value = await batcher(testcaseId) - return value ?? null - }, - } - }), -) - -export const testcaseValueAtomFamily = atomFamily( - ({testcaseId, path, runId}: {testcaseId: string; path: string; runId?: string | null}) => - selectAtom( - evaluationTestcaseQueryAtomFamily({testcaseId, runId}), - (queryState) => resolveTestcaseValueByPath(queryState.data, splitPath(path)), - Object.is, - ), -) - -export const testcaseQueryMetaAtomFamily = atomFamily( - ({testcaseId, runId}: {testcaseId: string; runId?: string | null}) => - selectAtom( - evaluationTestcaseQueryAtomFamily({testcaseId, runId}), - (queryState) => ({ - isLoading: queryState.isLoading, - isFetching: queryState.isFetching, - error: queryState.error, - }), - (a, b) => - a.isLoading === b.isLoading && a.isFetching === b.isFetching && a.error === b.error, - ), -) diff --git a/web/oss/src/components/EvalRunDetails/components/views/OverviewView/constants.ts b/web/oss/src/components/EvalRunDetails/components/views/OverviewView/constants.ts deleted file mode 100644 index fd67daa571..0000000000 --- a/web/oss/src/components/EvalRunDetails/components/views/OverviewView/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -export const INVOCATION_METRIC_KEYS = [ - "attributes.ag.metrics.costs.cumulative.total", - "attributes.ag.metrics.duration.cumulative", - "attributes.ag.metrics.tokens.cumulative.total", - "attributes.ag.metrics.errors.cumulative", -] as const - -export const INVOCATION_METRIC_LABELS: Record<(typeof INVOCATION_METRIC_KEYS)[number], string> = { - "attributes.ag.metrics.costs.cumulative.total": "Cost", - "attributes.ag.metrics.duration.cumulative": "Duration", - "attributes.ag.metrics.tokens.cumulative.total": "Tokens", - "attributes.ag.metrics.errors.cumulative": "Errors", -} - -export const DEFAULT_SPIDER_SERIES_COLOR = "#3B82F6" -export const SPIDER_SERIES_COLORS = ["#3B82F6", "#2563EB", "#DC2626", "#7C3AED", "#16A34A"] diff --git a/web/oss/src/components/EvalRunDetails/hooks/usePreviewTableData.ts b/web/oss/src/components/EvalRunDetails/hooks/usePreviewTableData.ts deleted file mode 100644 index 69329939ef..0000000000 --- a/web/oss/src/components/EvalRunDetails/hooks/usePreviewTableData.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {useMemo} from "react" - -import {useAtomValue} from "jotai" - -import { - evaluationEvaluatorsByRunQueryAtomFamily, - evaluationRunQueryAtomFamily, - tableColumnsAtomFamily, -} from "../atoms/table" -import type {EvaluationTableColumnsResult} from "../atoms/table" - -export interface PreviewTableData { - columnResult?: EvaluationTableColumnsResult - columnsPending: boolean -} - -export const usePreviewTableData = ({runId}: {runId: string}): PreviewTableData => { - const columnsAtom = useMemo(() => tableColumnsAtomFamily(runId), [runId]) - - const columnsResult = useAtomValue(columnsAtom) - const runQuery = useAtomValue(useMemo(() => evaluationRunQueryAtomFamily(runId), [runId])) - const evaluatorQuery = useAtomValue( - useMemo(() => evaluationEvaluatorsByRunQueryAtomFamily(runId), [runId]), - ) - - return { - columnResult: columnsResult, - columnsPending: - (runQuery.isPending && !runQuery.data) || - (runQuery.data && evaluatorQuery.isPending && !evaluatorQuery.data), - } -} - -export default usePreviewTableData diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsDeleteButton.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsDeleteButton.tsx deleted file mode 100644 index 433083b35f..0000000000 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsDeleteButton.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import {useMemo, useEffect} from "react" - -import {Trash} from "@phosphor-icons/react" -import {useAtom, useAtomValue, useSetAtom} from "jotai" - -import DeleteEvaluationModalButton from "@/oss/components/DeleteEvaluationModal/DeleteEvaluationModalButton" - -import {EVALUATION_RUNS_QUERY_KEY_ROOT} from "../atoms/tableStore" -import { - evaluationRunsMetaUpdaterAtom, - evaluationRunsSelectedRowKeysAtom, - evaluationRunsSelectionSnapshotAtom, - evaluationRunsDeleteContextAtom, - evaluationRunsTableResetAtom, - evaluationRunsDeleteModalOpenAtom, -} from "../atoms/view" - -const EvaluationRunsDeleteButton = () => { - const selection = useAtomValue(evaluationRunsSelectionSnapshotAtom) - const deleteContext = useAtomValue(evaluationRunsDeleteContextAtom) - const resetCallback = useAtomValue(evaluationRunsTableResetAtom) - const setSelectedRowKeys = useSetAtom(evaluationRunsSelectedRowKeysAtom) - const setMetaUpdater = useSetAtom(evaluationRunsMetaUpdaterAtom) - - const [open, setOpen] = useAtom(evaluationRunsDeleteModalOpenAtom) - - useEffect(() => { - if (!selection.hasSelection && open) { - setOpen(false) - } - }, [open, selection.hasSelection, setOpen]) - - const evaluationType = useMemo(() => { - if (selection.labels && selection.labels.length) { - return selection.labels - } - return "selected evaluations" - }, [selection.labels]) - - const deletionConfig = useMemo(() => { - if (!selection.hasSelection) return undefined - return { - evaluationKind: deleteContext.evaluationKind, - projectId: deleteContext.projectId, - previewRunIds: selection.previewRunIds, - invalidateQueryKeys: [EVALUATION_RUNS_QUERY_KEY_ROOT], - onSuccess: async () => { - setSelectedRowKeys([]) - resetCallback?.() - setMetaUpdater((prev) => ({...prev})) - setOpen(false) - }, - onError: () => { - setOpen(false) - }, - } - }, [ - deleteContext.evaluationKind, - deleteContext.projectId, - resetCallback, - selection.hasSelection, - selection.previewRunIds, - setMetaUpdater, - setSelectedRowKeys, - ]) - - const enabledTooltip = selection.hasSelection ? "Delete selected evaluations" : undefined - - return ( - 1} - deletionConfig={deletionConfig} - disabled={!selection.hasSelection} - disabledTooltip="Select evaluations to delete" - enabledTooltip={enabledTooltip} - buttonProps={{danger: true, icon: }} - open={open} - onOpenChange={setOpen} - > - Delete - - ) -} - -export default EvaluationRunsDeleteButton diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTableHeader.tsx b/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTableHeader.tsx deleted file mode 100644 index 31e5ee5da8..0000000000 --- a/web/oss/src/components/EvaluationRunsTablePOC/components/EvaluationRunsTableHeader.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import {Typography} from "antd" - -import EvaluationRunsCreateButton from "./EvaluationRunsCreateButton" -import EvaluationRunsDeleteButton from "./EvaluationRunsDeleteButton" -import EvaluationRunsHeaderFilters from "./filters/EvaluationRunsHeaderFilters" - -interface EvaluationRunsTableHeaderProps { - showFilters?: boolean - title?: React.ReactNode -} - -const EvaluationRunsTableHeader = ({showFilters = true, title}: EvaluationRunsTableHeaderProps) => ( -
-
- {showFilters ? ( - - ) : title ? ( - - {title} - - ) : null} -
- -
- - -
-
-) - -export default EvaluationRunsTableHeader diff --git a/web/oss/src/components/EvaluationRunsTablePOC/index.ts b/web/oss/src/components/EvaluationRunsTablePOC/index.ts deleted file mode 100644 index 176a9ed552..0000000000 --- a/web/oss/src/components/EvaluationRunsTablePOC/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export {default as EvaluationRunsTablePOC} from "./components/EvaluationRunsTable" -export {default as LatestEvaluationRunsTable} from "./components/LatestEvaluationRunsTable" -export {default as EvaluationRunsTableStoreProvider} from "./providers/EvaluationRunsTableStoreProvider" -export * from "./atoms/tableStore" -export type {EvaluationRunKind} from "./types" diff --git a/web/oss/src/components/EvaluationRunsTablePOC/providers/EvaluationRunsTableStoreProvider.tsx b/web/oss/src/components/EvaluationRunsTablePOC/providers/EvaluationRunsTableStoreProvider.tsx deleted file mode 100644 index 43475aeb12..0000000000 --- a/web/oss/src/components/EvaluationRunsTablePOC/providers/EvaluationRunsTableStoreProvider.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import type {PropsWithChildren} from "react" -import {useEffect, useMemo} from "react" - -import type {PrimitiveAtom} from "jotai" -import {Provider, createStore, useStore} from "jotai" - -import {recentAppIdAtom} from "@/oss/state/app/atoms/fetcher" -import {appStateSnapshotAtom} from "@/oss/state/appState" -import {sessionExistsAtom} from "@/oss/state/session" -import {activeInviteAtom} from "@/oss/state/url/auth" - -import { - type EvaluationRunsTableOverrides, - defaultEvaluationRunsTableOverrides, - evaluationRunsTableFetchEnabledAtom, - evaluationRunsTableOverridesAtom, -} from "../atoms/context" -import {evaluationRunsRefreshTriggerAtom} from "../atoms/tableStore" -import {evaluationRunsTablePageSizeAtom} from "../atoms/view" - -type WritableAtom = PrimitiveAtom & {write: any} - -const MIRRORED_GLOBAL_ATOMS: WritableAtom[] = [ - appStateSnapshotAtom as WritableAtom, - sessionExistsAtom as WritableAtom, - activeInviteAtom as WritableAtom, - recentAppIdAtom as WritableAtom, - evaluationRunsRefreshTriggerAtom as WritableAtom, -] - -interface EvaluationRunsTableStoreProviderProps extends PropsWithChildren { - overrides: Partial - pageSize: number -} - -const EvaluationRunsTableStoreProvider = ({ - overrides, - pageSize, - children, -}: EvaluationRunsTableStoreProviderProps) => { - const parentStore = useStore() - const resolvedOverrides = useMemo( - () => ({ - ...defaultEvaluationRunsTableOverrides, - ...overrides, - }), - [overrides], - ) - - const scopedStore = useMemo(() => { - const store = createStore() - MIRRORED_GLOBAL_ATOMS.forEach((atom) => { - store.set(atom, parentStore.get(atom)) - }) - store.set(evaluationRunsTablePageSizeAtom, pageSize) - store.set(evaluationRunsTableOverridesAtom, resolvedOverrides) - store.set(evaluationRunsTableFetchEnabledAtom, true) - return store - }, [pageSize, parentStore, resolvedOverrides]) - - useEffect(() => { - const cleanups = MIRRORED_GLOBAL_ATOMS.map((atom) => { - const sync = () => { - const value = parentStore.get(atom) - scopedStore.set(atom, value) - } - const unsub = parentStore.sub(atom, sync) - sync() - return unsub - }) - return () => cleanups.forEach((unsub) => unsub()) - }, [parentStore, scopedStore]) - - return {children} -} - -export default EvaluationRunsTableStoreProvider diff --git a/web/oss/src/components/Evaluations/atoms/runMetrics.ts b/web/oss/src/components/Evaluations/atoms/runMetrics.ts deleted file mode 100644 index 80147ddb4e..0000000000 --- a/web/oss/src/components/Evaluations/atoms/runMetrics.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "@/oss/components/EvalRunDetails/atoms/runMetrics" diff --git a/web/oss/src/components/Evaluators/index.tsx b/web/oss/src/components/Evaluators/index.tsx index e5023b500e..0d0d2bec9c 100644 --- a/web/oss/src/components/Evaluators/index.tsx +++ b/web/oss/src/components/Evaluators/index.tsx @@ -6,7 +6,10 @@ import { invalidateEvaluatorsListCache, workflowMolecule, } from "@agenta/entities/workflow" -import {workflowRevisionDrawerNavigationIdsAtom} from "@agenta/playground-ui/workflow-revision-drawer" +import { + openWorkflowRevisionDrawerAtom, + workflowRevisionDrawerNavigationIdsAtom, +} from "@agenta/playground-ui/workflow-revision-drawer" import {extractApiErrorMessage} from "@agenta/shared/utils" import {PageLayout} from "@agenta/ui" import {message} from "@agenta/ui/app-message" @@ -25,7 +28,6 @@ import { setOnboardingWidgetActivationAtom, } from "@/oss/lib/onboarding" import {appIdentifiersAtom, useQueryParamState} from "@/oss/state/appState" -import {openEvaluatorDrawerAtom} from "@/oss/state/evaluator/evaluatorDrawerStore" import {getProjectValues} from "@/oss/state/project" import {EVALUATOR_FULL_PAGE_NAV_ENABLED, recentEvaluatorIdAtom} from "@/oss/state/workflow" @@ -65,7 +67,7 @@ const EvaluatorsRegistry = ({scope = "project", mode = "active"}: EvaluatorsRegi const setOnboardingWidgetActivation = useSetAtom(setOnboardingWidgetActivationAtom) const [, setQueryRevision] = useQueryParamState("revisionId") - const openEvaluatorDrawer = useSetAtom(openEvaluatorDrawerAtom) + const openEvaluatorDrawer = useSetAtom(openWorkflowRevisionDrawerAtom) const openHumanDrawer = useSetAtom(openHumanEvaluatorDrawerAtom) const setNavigationIds = useSetAtom(workflowRevisionDrawerNavigationIdsAtom) @@ -228,7 +230,7 @@ const EvaluatorsRegistry = ({scope = "project", mode = "active"}: EvaluatorsRegi openEvaluatorDrawer({ entityId: localId, - mode: "create", + context: "evaluator-create", // The post-create routing (playground vs stay on /evaluators) // is owned by `useDrawerCreateCommitCallback` in the drawer // wrapper now — it reads the just-committed revision's URI / diff --git a/web/oss/src/components/EvaluationRunsTablePOC/components/filters/QuickDateRangePicker.tsx b/web/oss/src/components/Filters/QuickDateRangePicker.tsx similarity index 100% rename from web/oss/src/components/EvaluationRunsTablePOC/components/filters/QuickDateRangePicker.tsx rename to web/oss/src/components/Filters/QuickDateRangePicker.tsx diff --git a/web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx b/web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx deleted file mode 100644 index 74b9317082..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/InfiniteVirtualTable.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import {useEffect, useRef} from "react" - -import {useQueryClient} from "@tanstack/react-query" -import {Provider} from "jotai" -import {createStore} from "jotai/vanilla" -import type {Store} from "jotai/vanilla/store" - -import InfiniteVirtualTableInner from "./components/InfiniteVirtualTableInner" -import {useColumnVisibilityControls as useColumnVisibilityControlsFromContext} from "./context/ColumnVisibilityContext" -import {useVirtualTableScrollContainer} from "./context/VirtualTableScrollContainerContext" -import { - InfiniteVirtualTableStoreHydrator, - InfiniteVirtualTableStoreProvider, -} from "./providers/InfiniteVirtualTableStoreProvider" -import type { - ColumnVisibilityConfig, - ColumnVisibilityState, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, - ResizableColumnsConfig, -} from "./types" - -export {useVirtualTableScrollContainer} - -export const useColumnVisibilityControls = () => - useColumnVisibilityControlsFromContext() - -function InfiniteVirtualTable( - props: InfiniteVirtualTableProps, -) { - const {useIsolatedStore = false, store, ...rest} = props - const queryClient = useQueryClient() - const managedStoreRef = useRef(store ?? null) - - useEffect(() => { - if (store) { - managedStoreRef.current = store - } - }, [store]) - - if (!store && useIsolatedStore && !managedStoreRef.current) { - managedStoreRef.current = createStore() - } - - const activeStore = managedStoreRef.current - const content = - - if (!activeStore) { - return content - } - - return ( - - - {content} - - - ) -} - -export {InfiniteVirtualTableStoreProvider} - -export default InfiniteVirtualTable - -export type { - InfiniteVirtualTableRowSelection, - ResizableColumnsConfig, - ColumnVisibilityConfig, - ColumnVisibilityState, -} diff --git a/web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts b/web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts deleted file mode 100644 index 7254984850..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/atoms/columnHiddenKeys.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type {Key} from "react" - -import {atom, type PrimitiveAtom} from "jotai" -import {atomFamily} from "jotai/utils" -import {atomWithStorage} from "jotai/utils" - -type HiddenKeysAtom = PrimitiveAtom - -interface HiddenKeysParams { - storageKey: string | null - defaults: Key[] - signature: string - version: number -} - -const METADATA_SUFFIX = "__meta" - -interface HiddenKeysMeta { - version: number - updatedAt: number -} - -const arraysEqual = (a: Key[], b: Key[]) => { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i += 1) { - if (a[i] !== b[i]) return false - } - return true -} - -const hiddenKeysAtomFamily = atomFamily( - ({storageKey, defaults, version}: HiddenKeysParams): HiddenKeysAtom => { - if (!storageKey) { - return atom(defaults) - } - if (typeof window === "undefined") { - return atom(defaults) - } - - const metaStorageKey = `${storageKey}${METADATA_SUFFIX}` - const metaAtom = atomWithStorage( - metaStorageKey, - {version, updatedAt: Date.now()}, - { - getItem: (key, initialValue) => { - try { - const raw = window.localStorage.getItem(key) - if (!raw) return initialValue - const parsed = JSON.parse(raw) - if (typeof parsed?.version === "number") { - return parsed as HiddenKeysMeta - } - } catch { - // ignore - } - return initialValue - }, - setItem: (key, newValue) => { - try { - window.localStorage.setItem(key, JSON.stringify(newValue)) - } catch { - // ignore - } - }, - removeItem: (key) => { - try { - window.localStorage.removeItem(key) - } catch { - // ignore - } - }, - }, - ) - - if (!storageKey) { - return atom(defaults) - } - if (typeof window === "undefined") { - return atom(defaults) - } - const storageAtom = atomWithStorage(storageKey, defaults) - - return atom( - (get, set) => { - const meta = get(metaAtom) - if (meta.version !== version) { - set(storageAtom, defaults) - set(metaAtom, {version, updatedAt: Date.now()}) - return defaults - } - return get(storageAtom) - }, - (get, set, next: Key[] | ((prev: Key[]) => Key[])) => { - const current = get(storageAtom) - const resolved = typeof next === "function" ? next(current) : next - set(storageAtom, resolved) - set(metaAtom, {version, updatedAt: Date.now()}) - }, - ) as HiddenKeysAtom - }, - (a, b) => - (a.storageKey ?? null) === (b.storageKey ?? null) && - a.version === b.version && - (a.signature === b.signature || arraysEqual(a.defaults, b.defaults)), -) - -export const getColumnHiddenKeysAtom = ( - storageKey?: string, - defaultHiddenKeys: Key[] = [], -): HiddenKeysAtom => - hiddenKeysAtomFamily({ - storageKey: storageKey ?? null, - defaults: defaultHiddenKeys, - signature: defaultHiddenKeys.join("|"), - version: defaultHiddenKeys.length, - }) diff --git a/web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts b/web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts deleted file mode 100644 index 074385124b..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/atoms/columnVisibility.ts +++ /dev/null @@ -1,268 +0,0 @@ -import {atom} from "jotai" -import {atomFamily, selectAtom} from "jotai/utils" -import {atomWithImmer} from "jotai-immer" - -import type {ColumnViewportVisibilityEvent} from "../types" - -const DEFAULT_SCOPE = "__default__" -const resolveScopeKey = (scopeId: string | null) => scopeId ?? DEFAULT_SCOPE - -type ColumnVisibilityState = Map> -type ColumnVisibilityUserState = Record> - -const createScopeMap = () => new Map() -const EMPTY_SCOPE_MAP = createScopeMap() - -const columnVisibilityStateAtom = atomWithImmer(new Map()) -const defaultVisibilityAtom = atom(false) - -// const visibilityDebugEnabled = process.env.NEXT_PUBLIC_EVAL_RUN_DEBUG === "true" - -// const logStateTable = ( -// scopeId: string | null, -// previous: Record, -// next: Record, -// ) => { -// if (!visibilityDebugEnabled || typeof window === "undefined") return -// // const timestamp = new Date().toISOString() -// // const scopeLabel = scopeId ? `scope:${scopeId}` : "scope:none" -// const keys = Array.from(new Set([...Object.keys(previous), ...Object.keys(next)])).sort() -// const rows = keys -// .map((column) => { -// const prev = previous[column] ?? false -// const nextValue = next[column] ?? false -// if (prev === nextValue) { -// return null -// } -// return { -// column, -// prev, -// next: nextValue, -// } -// }) -// .filter((row): row is {column: string; prev: boolean; next: boolean} => row !== null) -// if (!rows.length) { -// return -// } -// // try { -// // console.groupCollapsed("[infiniteTable][columnVisibility]", `${timestamp} ${scopeLabel}`) -// // console.table(rows) -// // console.groupEnd() -// // } catch (error) { -// // console.debug("[infiniteTable][columnVisibility] log failed", error) -// // } -// } - -type ColumnViewportVisibilityPayload = - | ColumnViewportVisibilityEvent - | ColumnViewportVisibilityEvent[] - -export const setColumnViewportVisibilityAtom = atom( - null, - (get, set, payload: ColumnViewportVisibilityPayload) => { - const updates = Array.isArray(payload) ? payload : [payload] - if (!updates.length) { - return - } - - set(columnVisibilityStateAtom, (draft) => { - updates.forEach((update) => { - const scopeKey = resolveScopeKey(update.scopeId) - let scopeMap = draft.get(scopeKey) - if (!scopeMap) { - scopeMap = new Map() - draft.set(scopeKey, scopeMap) - } - const previousValue = scopeMap.get(update.columnKey) ?? false - if (previousValue === update.visible) { - return - } - scopeMap.set(update.columnKey, update.visible) - }) - }) - }, -) - -/** - * Delete column visibility state from the atom - * Use when columns are removed from DOM to prevent stale visibility state - */ -export const deleteColumnViewportVisibilityAtom = atom( - null, - ( - get, - set, - payload: - | {scopeId: string | null; columnKey: string} - | {scopeId: string | null; columnKey: string}[], - ) => { - const deletions = Array.isArray(payload) ? payload : [payload] - if (!deletions.length) { - return - } - - set(columnVisibilityStateAtom, (draft) => { - deletions.forEach((deletion) => { - const scopeKey = resolveScopeKey(deletion.scopeId) - const scopeMap = draft.get(scopeKey) - if (scopeMap) { - scopeMap.delete(deletion.columnKey) - } - }) - }) - }, -) - -const viewportStateAtomFamily = atomFamily( - (scopeId: string | null) => - atom( - (get) => - get(columnVisibilityStateAtom).get(resolveScopeKey(scopeId)) ?? EMPTY_SCOPE_MAP, - ), - (a, b) => resolveScopeKey(a) === resolveScopeKey(b), -) - -const columnViewportVisibilityAtomFamily = atomFamily( - ({scopeId, columnKey}: {scopeId: string | null; columnKey: string}) => - selectAtom( - viewportStateAtomFamily(scopeId), - // Always default to true (visible) for columns not yet tracked - // This ensures: - // 1. Cells render immediately on scope change (e.g., revision switch) - // 2. Newly expanded column groups show content immediately - // 3. IntersectionObserver will set to false if outside viewport - (state) => state.get(columnKey) ?? true, - (a, b) => a === b, - ), - (a, b) => - resolveScopeKey(a.scopeId) === resolveScopeKey(b.scopeId) && a.columnKey === b.columnKey, -) - -export const getColumnViewportVisibilityAtom = ( - scopeId: string | null, - columnKey: string | undefined, -) => { - if (!scopeId || !columnKey) { - return defaultVisibilityAtom - } - return columnViewportVisibilityAtomFamily({scopeId, columnKey}) -} - -const userVisibilityStateAtom = atomWithImmer({}) - -const userStateAtomFamily = atomFamily( - (scopeId: string | null) => - atom((get) => get(userVisibilityStateAtom)[resolveScopeKey(scopeId)] ?? {}), - (a, b) => resolveScopeKey(a) === resolveScopeKey(b), -) - -export const setColumnUserVisibilityAtom = atom( - null, - ( - get, - set, - update: { - scopeId: string | null - columnKey: string - visible: boolean - }, - ) => { - const scopeKey = resolveScopeKey(update.scopeId) - const prevState = get(userVisibilityStateAtom) - const prevScopeEntries = prevState[scopeKey] ?? {} - const previousValue = prevScopeEntries[update.columnKey] ?? false - if (previousValue === update.visible) { - return - } - - set(userVisibilityStateAtom, (draft) => { - if (!draft[scopeKey]) { - draft[scopeKey] = {} - } - draft[scopeKey][update.columnKey] = update.visible - }) - }, -) - -const columnUserVisibilityAtomFamily = atomFamily( - ({scopeId, columnKey}: {scopeId: string | null; columnKey: string}) => - selectAtom( - userStateAtomFamily(scopeId), - (state) => { - const scopedValue = state[columnKey] - return scopedValue === undefined ? true : scopedValue - }, - (a, b) => a === b, - ), - (a, b) => - resolveScopeKey(a.scopeId) === resolveScopeKey(b.scopeId) && a.columnKey === b.columnKey, -) - -export const getColumnUserVisibilityAtom = ( - scopeId: string | null, - columnKey: string | undefined, -) => { - if (!scopeId || !columnKey) { - return defaultVisibilityAtom - } - return columnUserVisibilityAtomFamily({scopeId, columnKey}) -} - -export const getColumnEffectiveVisibilityAtom = ( - scopeId: string | null, - columnKey: string | undefined, -) => { - if (!scopeId || !columnKey) { - return defaultVisibilityAtom - } - const userAtom = getColumnUserVisibilityAtom(scopeId, columnKey) - const viewportAtom = getColumnViewportVisibilityAtom(scopeId, columnKey) - return atom((get) => get(userAtom) && get(viewportAtom)) -} - -// const scopeVisibilityMapAtomFamily = atomFamily((scopeId: string | null) => -// selectAtom( -// atom((get) => { -// const viewportState = get(viewportStateAtomFamily(scopeId)) -// const userState = get(userStateAtomFamily(scopeId)) -// const keys = new Set([...Object.keys(viewportState), ...Object.keys(userState)]) -// const next: Record = {} -// keys.forEach((key) => { -// const viewportVisible = viewportState[key] -// const userVisible = userState[key] -// next[key] = -// (userVisible === undefined ? true : userVisible) && -// (viewportVisible === undefined ? false : viewportVisible) -// }) -// return next -// }), -// (a, b) => deepEqual(resolveScopeKey(a), resolveScopeKey(b)), -// ), -// ) - -// export const getScopeVisibilityMapAtom = (scopeId: string | null) => - -export const scopedColumnVisibilityAtomFamily = atomFamily( - ({scopeId, columnKey}: {scopeId: string | null; columnKey: string}) => - columnViewportVisibilityAtomFamily({scopeId, columnKey}), - (a, b) => - resolveScopeKey(a.scopeId) === resolveScopeKey(b.scopeId) && a.columnKey === b.columnKey, -) - -// export const getScopedColumnVisibilityAtom = (scopeId: string | null, columnKey?: string) => { -// if (!columnKey) { -// return defaultVisibilityAtom -// } -// return selectAtom( -// scopeVisibilityMapAtomFamily(scopeId), -// (state) => { -// const explicit = state[columnKey] -// console.log("scopeVisibilityMapAtomFamily", state) -// if (typeof explicit === "boolean") { -// return explicit -// } -// return true -// }, -// (a, b) => a === b, -// ) -// } diff --git a/web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts b/web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts deleted file mode 100644 index a89c3f76b6..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/atoms/columnWidths.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {atom, type PrimitiveAtom} from "jotai" - -type ColumnWidthAtom = PrimitiveAtom> - -const DEFAULT_SCOPE = "__default__" -const scopeKey = (scopeId: string | null | undefined) => scopeId ?? DEFAULT_SCOPE - -const atomCache = new Map() - -const createColumnWidthsAtom = (scopeId: string | null | undefined) => { - const key = scopeKey(scopeId) - const cached = atomCache.get(key) - if (cached) { - return cached - } - - // Use simple atom without storage - widths are session-only and reset on navigation - const safeAtom: ColumnWidthAtom = atom>({}) - - atomCache.set(key, safeAtom) - return safeAtom -} - -export const getColumnWidthsAtom = (scopeId: string | null | undefined) => - createColumnWidthsAtom(scopeId) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx b/web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx deleted file mode 100644 index 039049a921..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/cells.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import {useEffect, memo, useRef, useState, type ReactNode} from "react" - -import clsx from "clsx" - -import {useColumnVisibilityFlag} from "../context/ColumnVisibilityFlagContext" - -import type {TableColumnCell} from "./types" - -export const createTextCell = (opts: { - getValue: (row: Row) => ReactNode - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: opts.getValue, - align: opts.align, - className: clsx("ivt-cell ivt-cell--text", opts.className), -}) - -export const createComponentCell = (opts: { - render: (row: Row, index: number) => ReactNode - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: opts.render, - align: opts.align, - className: clsx(opts.className), -}) - -export const createStatusCell = (opts?: { - formatter?: (status: ReactNode, row: Row) => ReactNode - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: (row) => { - const value = row.status ?? null - return opts?.formatter ? opts.formatter(value, row) : value - }, - align: opts?.align ?? "left", - className: clsx("ivt-cell ivt-cell--status", opts?.className), -}) - -export const createActionsCell = (opts: { - render: (row: Row) => ReactNode - className?: string -}): TableColumnCell => ({ - render: (row) => opts.render(row), - className: clsx("ivt-cell ivt-cell--actions", opts.className), - align: "center", -}) - -const VisibilityObserverCell = ({ - row, - index, - render, - onVisible, - rootMargin, - once, - placeholder, -}: { - row: Row - index: number - render: (row: Row, index: number, isVisible: boolean) => ReactNode - onVisible?: (row: Row, index: number) => void - rootMargin?: string - once?: boolean - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) -}) => { - const ref = useRef(null) - const hasTriggeredRef = useRef(false) - const [isVisible, setIsVisible] = useState(!onVisible) - - useEffect(() => { - if (!onVisible) return - const element = ref.current - if (!element) return - let unsubscribed = false - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0] - if (entry?.isIntersecting) { - setIsVisible(true) - if (once && hasTriggeredRef.current) return - onVisible(row, index) - if (once) { - hasTriggeredRef.current = true - observer.disconnect() - unsubscribed = true - } - } else if (!once) { - setIsVisible(false) - } - }, - {rootMargin}, - ) - observer.observe(element) - return () => { - if (!unsubscribed) { - observer.disconnect() - } - } - }, [index, onVisible, once, rootMargin, row]) - - const content = - !isVisible && placeholder - ? typeof placeholder === "function" - ? placeholder(row, index) - : placeholder - : render(row, index, isVisible) - - return ( -
- {content} -
- ) -} - -export const createViewportAwareCell = (opts: { - render: (row: Row, index: number, isVisible: boolean) => ReactNode - onVisible?: (row: Row, index: number) => void - rootMargin?: string - align?: "left" | "right" | "center" - className?: string - once?: boolean - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) -}): TableColumnCell => ({ - render: (row, index) => ( - - row={row} - index={index} - render={opts.render} - onVisible={opts.onVisible} - rootMargin={opts.rootMargin} - once={opts.once} - placeholder={opts.placeholder} - /> - ), - align: opts.align, - className: clsx("ivt-cell ivt-cell--viewport-wrapper", opts.className), -}) - -const ColumnVisibilityAwareCell = memo( - ({ - row, - index, - columnKey, - render, - placeholder, - keepMounted = false, - }: { - row: Row - index: number - columnKey?: string - render: (row: Row, index: number, isVisible: boolean) => ReactNode - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) - keepMounted?: boolean - }) => { - const isVisible = useColumnVisibilityFlag(columnKey) - if (!keepMounted && !isVisible) { - if (placeholder) { - return ( -
- {typeof placeholder === "function" ? placeholder(row, index) : placeholder} -
- ) - } - return null - } - const content = render(row, index, isVisible) - - if (!content && !placeholder) { - if (!keepMounted) { - return null - } - return ( -
- ) - } - - return ( -
- {content ?? - (typeof placeholder === "function" ? placeholder(row, index) : placeholder)} -
- ) - }, -) - -export const createColumnVisibilityAwareCell = (opts: { - columnKey?: string - render: (row: Row, index: number, isVisible: boolean) => ReactNode - placeholder?: ReactNode | ((row: Row, index: number) => ReactNode) - keepMounted?: boolean - align?: "left" | "right" | "center" - className?: string -}): TableColumnCell => ({ - render: (row, index) => ( - - row={row} - index={index} - columnKey={opts.columnKey} - render={opts.render} - placeholder={opts.placeholder} - keepMounted={opts.keepMounted} - /> - ), - align: opts.align, - className: clsx("ivt-cell ivt-cell--column-visibility-wrapper", opts.className), -}) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx b/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx deleted file mode 100644 index 1dc8c6f722..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/createStandardColumns.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import type {ReactNode} from "react" - -import {MoreOutlined} from "@ant-design/icons" -import {Copy, DownloadSimple} from "@phosphor-icons/react" -import {Button, Dropdown, Tooltip} from "antd" -import type {ColumnsType, ColumnType} from "antd/es/table" - -import {UserReference} from "@/oss/components/References" -import {copyToClipboard} from "@/oss/lib/helpers/copyToClipboard" - -import ColumnVisibilityMenuTrigger from "../components/columnVisibility/ColumnVisibilityMenuTrigger" -import type {InfiniteTableRowBase} from "../types" - -export interface TextColumnDef { - type: "text" - key: string - title: string - width?: number - render?: (value: any, record: any) => ReactNode - /** Pin column to left or right */ - fixed?: "left" | "right" - /** Lock column from being hidden in visibility menu (defaults to true if fixed is set) */ - columnVisibilityLocked?: boolean -} - -export interface DateColumnDef { - type: "date" - key: string - title: string - width?: number - /** Custom date formatter (default: formatDate from helpers) */ - format?: (date: string) => string -} - -export interface UserColumnDef { - type: "user" - /** The key in the record that contains the user ID */ - key: string - title: string - width?: number - /** Custom user ID extractor (default: uses record[key]) */ - getUserId?: (record: T) => string | null | undefined -} - -export interface ActionItem { - key: string - label: string - icon?: ReactNode - danger?: boolean - onClick: (record: T, event?: any) => void - /** Hide this action conditionally */ - hidden?: (record: T) => boolean -} - -export interface ActionDivider { - type: "divider" - hidden?: (record: T) => boolean -} - -export interface ActionsColumnDef { - type: "actions" - items: (ActionItem | ActionDivider)[] - width?: number - /** Maximum width for the actions column */ - maxWidth?: number - /** Show copy ID action (default: true) */ - showCopyId?: boolean - /** Custom ID extractor for copy action */ - getRecordId?: (record: T) => string - /** Show copy slug action (default: false — requires getSlug to yield a value) */ - showCopySlug?: boolean - /** Slug extractor for copy-slug action */ - getSlug?: (record: T) => string | null | undefined - /** Export row callback */ - onExportRow?: (record: T) => void - /** Whether export is currently in progress */ - isExporting?: boolean -} - -export type StandardColumnDef = - | TextColumnDef - | DateColumnDef - | UserColumnDef - | ActionsColumnDef - -/** - * Create standard table columns from simplified definitions. - * Reduces boilerplate for common column types. - * - * @example - * ```tsx - * const columns = createStandardColumns([ - * { type: "text", key: "name", title: "Name", width: 300 }, - * { type: "date", key: "updated_at", title: "Date Modified" }, - * { type: "date", key: "created_at", title: "Date Created" }, - * { - * type: "actions", - * items: [ - * { key: "view", label: "View details", icon: , onClick: handleView }, - * { key: "clone", label: "Clone", icon: , onClick: handleClone }, - * { type: "divider" }, - * { key: "rename", label: "Rename", icon: , onClick: handleRename }, - * { key: "delete", label: "Delete", icon: , danger: true, onClick: handleDelete }, - * ], - * }, - * ]) - * ``` - */ -export function createStandardColumns( - defs: StandardColumnDef[], -): ColumnsType { - return defs.map((def) => { - switch (def.type) { - case "text": - return createTextColumn(def) - case "date": - return createDateColumn(def) - case "user": - return createUserColumn(def) - case "actions": - return createActionsColumn(def) - default: - throw new Error(`Unknown column type: ${(def as any).type}`) - } - }) -} - -function createTextColumn(def: TextColumnDef): ColumnType { - return { - title: def.title, - dataIndex: def.key, - key: def.key, - width: def.width, - fixed: def.fixed, - render: def.render, - // Lock column from being toggled in visibility menu (explicit or derived from fixed) - columnVisibilityLocked: def.columnVisibilityLocked ?? Boolean(def.fixed), - onHeaderCell: () => ({ - style: {minWidth: def.width || 220}, - }), - } as ColumnType -} - -const formatDateCell = (value?: string | null) => { - if (!value) return "—" - try { - return new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - }).format(new Date(value)) - } catch { - return value - } -} - -function createDateColumn(def: DateColumnDef): ColumnType { - return { - title: def.title, - dataIndex: def.key, - key: def.key, - width: def.width || 200, - render: (date: string) => { - const formatted = !date ? "—" : def.format ? def.format(date) : formatDateCell(date) - return
{formatted}
- }, - onHeaderCell: () => ({ - style: {minWidth: def.width || 180}, - }), - } -} - -function createActionsColumn( - def: ActionsColumnDef, -): ColumnType { - const { - items, - width = 56, // TODO: try 61px here - maxWidth, - showCopyId = true, - getRecordId, - showCopySlug = false, - getSlug, - onExportRow, - isExporting, - } = def - - const defaultGetId = (record: T): string => { - if (getRecordId) return getRecordId(record) - const id = (record as any).id || (record as any)._id || (record as any).key - if (typeof id === "string") return id - return "" - } - - return { - title: , - key: "actions", - width, - ...(maxWidth ? {maxWidth} : {}), - fixed: "right", - align: "center", - // Lock actions column from being toggled in visibility menu - columnVisibilityLocked: true as any, - onCell: () => ({className: "ag-table-actions-cell"}), - render: (_, record) => { - if (record.__isSkeleton) return null - - // Build menu items from config - const menuItems: any[] = [] - - items.forEach((item) => { - if ("type" in item && item.type === "divider") { - const dividerItem = item as ActionDivider - // Skip if hidden - if (dividerItem.hidden?.(record)) { - return - } - menuItems.push({type: "divider"}) - return - } - - const actionItem = item as ActionItem - - // Skip if hidden - if (actionItem.hidden?.(record)) { - return - } - - menuItems.push({ - key: actionItem.key, - label: actionItem.label, - icon: actionItem.icon, - danger: actionItem.danger, - onClick: (e: any) => { - e.domEvent.stopPropagation() - actionItem.onClick(record, e) - }, - }) - }) - - // Add export row if enabled - if (onExportRow) { - menuItems.push({ - key: "export-row", - label: "Export row", - icon: , - disabled: isExporting, - onClick: (e: any) => { - e.domEvent.stopPropagation() - if (!isExporting) { - onExportRow(record) - } - }, - }) - } - - // Add copy ID if enabled - if (showCopyId) { - const recordId = defaultGetId(record) - if (recordId) { - if ( - menuItems.length > 0 && - menuItems[menuItems.length - 1].type !== "divider" - ) { - menuItems.push({type: "divider"}) - } - menuItems.push({ - key: "copy-id", - label: "Copy ID", - icon: , - onClick: (e: any) => { - e.domEvent.stopPropagation() - copyToClipboard(recordId) - }, - }) - } - } - - // Add copy slug if enabled - if (showCopySlug && getSlug) { - const slug = getSlug(record) - if (slug) { - menuItems.push({ - key: "copy-slug", - label: "Copy Slug", - icon: , - onClick: (e: any) => { - e.domEvent.stopPropagation() - copyToClipboard(slug) - }, - }) - } - } - - return ( -
e.stopPropagation()} - > - - -
- ) - }, - } -} - -function createUserColumn(def: UserColumnDef): ColumnType { - const {key, title, width = 180, getUserId} = def - - return { - title, - dataIndex: key, - key, - width, - render: (value: string | null | undefined, record: T) => { - if (record.__isSkeleton) return null - const userId = getUserId ? getUserId(record) : value - return ( -
- -
- ) - }, - onHeaderCell: () => ({ - style: {minWidth: width}, - }), - } -} - -// Export individual column creators for custom use -export {createTextColumn, createDateColumn, createUserColumn, createActionsColumn} diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts b/web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts deleted file mode 100644 index 5cfb17d902..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/createTableColumns.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type {MouseEvent, ReactNode} from "react" - -import type {ColumnsType} from "antd/es/table" -import clsx from "clsx" - -import type {TableColumnConfig, TableColumnGroup, TableColumnCell} from "./types" - -type ColumnWithChildren = ColumnsType[number] & { - children?: ColumnsType -} - -type OnHeaderCell = ColumnsType[number]["onHeaderCell"] -type OnHeaderCellArgs = Parameters>> -type OnHeaderCellResult = ReturnType>> - -const normalizeGroups = ( - groups: TableColumnGroup[], -): TableColumnConfig[] => - groups.flatMap((group) => { - if (Array.isArray(group)) { - return group - } - return [group] - }) - -const resolveTitle = ( - config: TableColumnConfig, - depth: number, -): ReactNode => { - if (typeof config.title === "function") { - return config.title({column: config, depth}) - } - return config.title -} - -const applyCellRenderer = ( - column: ColumnsType[number], - cell?: TableColumnCell, -) => { - if (!cell) return - column.render = (_value, record: Row, index) => cell.render(record, index) - column.align = cell.align ?? column.align - column.className = clsx(column.className, cell.className) -} - -const buildColumn = ( - config: TableColumnConfig, - depth = 0, -): ColumnsType[number] => { - const column: ColumnWithChildren = { - key: config.key, - title: resolveTitle(config, depth), - width: config.width, - fixed: config.fixed, - align: config.align, - ellipsis: config.ellipsis, - className: clsx(config.className), - shouldCellUpdate: config.shouldCellUpdate, - } - - applyCellRenderer(column, config.cell) - - if (config.children?.length) { - column.children = config.children.map((child) => buildColumn(child, depth + 1)) - } - - if (config.minWidth || config.flex) { - const prev = config.columnProps?.onHeaderCell - column.onHeaderCell = (...args: OnHeaderCellArgs): OnHeaderCellResult => { - const baseStyle: React.CSSProperties = { - minWidth: config.minWidth, - flex: config.flex, - } - const prevResult = typeof prev === "function" ? prev(...args) : undefined - return { - ...(prevResult ?? {}), - style: {...baseStyle, ...(prevResult?.style ?? {})}, - } - } - } - - if (config.columnProps) { - const {className, render, ...rest} = config.columnProps - column.className = clsx(column.className, className) - Object.assign(column, rest) - if (!column.render && render) { - column.render = render - } - } - - if (config.visibilityKey) { - ;(column as any)["data-column-visibility-key"] = config.visibilityKey - } - - if (config.visibilityLabel) { - ;(column as any).columnVisibilityLabel = config.visibilityLabel - } - - if (config.visibilityLocked) { - ;(column as any).columnVisibilityLocked = true - } - - if (config.visibilityTitle) { - ;(column as any).columnVisibilityTitle = config.visibilityTitle - } - - if (config.defaultHidden) { - ;(column as any).defaultHidden = true - } - - if (config.exportLabel) { - ;(column as any).exportLabel = config.exportLabel - } - - if (config.exportEnabled === false) { - ;(column as any).exportEnabled = false - } - - if (config.exportDataIndex) { - ;(column as any).exportDataIndex = config.exportDataIndex - } - - if (config.exportValue) { - ;(column as any).exportValue = config.exportValue - } - - if (config.exportFormatter) { - ;(column as any).exportFormatter = config.exportFormatter - } - - if (config.exportMetadata !== undefined) { - ;(column as any).exportMetadata = config.exportMetadata - } - - // Auto-stop click propagation in action columns so clicks on empty cell area - // don't bubble to the row navigation handler. - if (config.key === "actions") { - const prevOnCell = column.onCell as ((record: Row, index?: number) => any) | undefined - column.onCell = (record: Row, index?: number) => { - const base = prevOnCell ? prevOnCell(record, index) : {} - const prevClick = (base as any)?.onClick - return { - ...base, - className: clsx((base as any)?.className, "ag-table-actions-cell"), - onClick: (e: MouseEvent) => { - e.stopPropagation() - prevClick?.(e) - }, - } - } - } - - return column -} - -export const createTableColumns = ( - groups: TableColumnGroup[], -): ColumnsType => normalizeGroups(groups).map((config) => buildColumn(config)) diff --git a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts b/web/oss/src/components/InfiniteVirtualTable/columns/types.ts deleted file mode 100644 index 413df537a5..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/columns/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type {Key, ReactNode} from "react" - -import type {ColumnsType} from "antd/es/table" - -export interface TableColumnCell { - render: (row: Row, rowIndex: number) => ReactNode - align?: "left" | "right" | "center" - className?: string -} - -export interface TableColumnConfig { - key: Key - title?: ReactNode | ((context: {column: TableColumnConfig; depth: number}) => ReactNode) - width?: number - minWidth?: number - flex?: number - align?: "left" | "right" | "center" - fixed?: "left" | "right" - ellipsis?: boolean - className?: string - defaultHidden?: boolean - visibilityKey?: string - visibilityLabel?: string - visibilityLocked?: boolean - visibilityTitle?: ReactNode - cell?: TableColumnCell - children?: TableColumnConfig[] - columnProps?: Partial[number]> - shouldCellUpdate?: ColumnsType[number]["shouldCellUpdate"] - exportLabel?: string - exportEnabled?: boolean - exportDataIndex?: ColumnsType[number]["dataIndex"] - exportValue?: (row: Row, column?: ColumnsType[number], columnIndex?: number) => unknown - exportFormatter?: ( - value: unknown, - row: Row, - column?: ColumnsType[number], - columnIndex?: number, - ) => string | undefined - exportMetadata?: unknown -} - -export type TableColumnGroup = TableColumnConfig | TableColumnConfig[] - -export type TableColumnsBuilder = ( - config: TableColumnGroup[], -) => ColumnsType diff --git a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx b/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx deleted file mode 100644 index 6bb9d61c6a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityHeader.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import {memo, forwardRef, useCallback, type MutableRefObject, type ReactNode} from "react" - -import {useColumnVisibilityContext} from "../context/ColumnVisibilityContext" - -export type VisibilityRegistrationHandler = (columnKey: string, node: HTMLElement | null) => void - -interface ColumnVisibilityHeaderProps { - columnKey: string - columnVisibilityLabel?: string - children: ReactNode -} - -const ColumnVisibilityHeader = forwardRef( - ({columnKey, children}, ref) => { - const {registerHeader} = useColumnVisibilityContext() - - const mergedRef = useCallback( - (node: HTMLSpanElement | null) => { - const thNode = node?.closest("th") - const target = (thNode as HTMLElement | null) ?? (node as HTMLElement | null) - if (thNode) { - thNode.dataset.columnKey = columnKey - } - - if (registerHeader) { - registerHeader(columnKey, target) - } - if (typeof ref === "function") { - ref(node) - } else if (ref && typeof ref === "object") { - ;(ref as MutableRefObject).current = node - } - }, - [columnKey, ref, registerHeader], - ) - - return ( - - {children} - - ) - }, -) - -export default memo(ColumnVisibilityHeader) diff --git a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx b/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx deleted file mode 100644 index 9d4ec9eee8..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/ColumnVisibilityTrigger.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type {MouseEvent, ReactNode} from "react" -import {useMemo, useState} from "react" - -import {GearSix} from "@phosphor-icons/react" -import {Button, Checkbox, Divider, Popover, Space, Tooltip} from "antd" - -import type {ColumnVisibilityState} from "../types" - -type ColumnVisibilityControls = ColumnVisibilityState - -interface ColumnVisibilityTriggerProps { - controls: ColumnVisibilityControls - variant?: "button" | "icon" - label?: string - renderContent?: (controls: ColumnVisibilityControls, close: () => void) => ReactNode -} - -const DefaultVisibilityContent = ({ - controls, - onClose, -}: { - controls: ColumnVisibilityControls - onClose: () => void -}) => { - const nodes = useMemo(() => controls.columnTree, [controls.columnTree]) - - const renderNodes = (tree: typeof nodes, depth = 0): ReactNode => - tree.map((node) => { - const label = node.titleNode ?? node.label ?? node.key - const childNodes = node.children?.length ? renderNodes(node.children, depth + 1) : null - const isGroup = Boolean(node.children?.length) - return ( -
- - isGroup - ? controls.toggleTree(node.key) - : controls.toggleColumn(node.key) - } - style={{marginLeft: depth ? depth * 12 : 0}} - > - {label} - - {childNodes} -
- ) - }) - - return ( - -
Toggle columns
-
{renderNodes(nodes)}
- -
- - -
-
- ) -} - -const ColumnVisibilityTrigger = ({ - controls, - variant = "button", - label = "Columns", - renderContent, -}: ColumnVisibilityTriggerProps) => { - const [open, setOpen] = useState(false) - const {leafKeys, isHidden} = controls - - const visibleLeafCount = useMemo( - () => leafKeys.filter((key) => !isHidden(key)).length, - [leafKeys, isHidden], - ) - - const stopPropagation = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - } - - const triggerNode = - variant === "icon" ? ( - - - ) - - const content = renderContent ? ( - renderContent(controls, () => setOpen(false)) - ) : ( - setOpen(false)} /> - ) - - return ( - setOpen(value)} - content={content} - > - {triggerNode} - - ) -} - -export default ColumnVisibilityTrigger diff --git a/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx deleted file mode 100644 index c2c544eb5f..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ /dev/null @@ -1,660 +0,0 @@ -import { - memo, - useCallback, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react" - -import {Table} from "antd" -import type {TableProps} from "antd/es/table" -import clsx from "clsx" -import {useSetAtom} from "jotai" - -import { - deleteColumnViewportVisibilityAtom, - setColumnUserVisibilityAtom, - setColumnViewportVisibilityAtom, -} from "../atoms/columnVisibility" -import {type VisibilityRegistrationHandler} from "../components/ColumnVisibilityHeader" -import {ColumnVisibilityFlagProvider} from "../context/ColumnVisibilityFlagContext" -import VirtualTableScrollContainerContext from "../context/VirtualTableScrollContainerContext" -import useColumnVisibility from "../hooks/useColumnVisibility" -import useColumnVisibilityControlsBuilder from "../hooks/useColumnVisibilityControls" -import useContainerResize from "../hooks/useContainerResize" -import useExpandableRows from "../hooks/useExpandableRows" -import useHeaderViewportVisibility from "../hooks/useHeaderViewportVisibility" -import useInfiniteScroll from "../hooks/useInfiniteScroll" -import useScrollContainer from "../hooks/useScrollContainer" -import useSmartResizableColumns from "../hooks/useSmartResizableColumns" -import useTableKeyboardShortcuts from "../hooks/useTableKeyboardShortcuts" -import {shouldIgnoreRowClick} from "../hooks/useTableManager" -import useTableRowSelection from "../hooks/useTableRowSelection" -import ColumnVisibilityProvider from "../providers/ColumnVisibilityProvider" -import type {InfiniteVirtualTableProps} from "../types" -import { - buildColumnDescendantMap, - collectFixedColumnKeys, - mergeHandlers, - shallowEqual, -} from "../utils/columnUtils" - -const scopeUsageCounts = new Map() - -type InfiniteVirtualTableInnerProps = Omit< - InfiniteVirtualTableProps, - "useIsolatedStore" | "store" -> - -const InfiniteVirtualTableInnerBase = ({ - columns, - dataSource, - loadMore, - rowKey, - active = true, - scrollThreshold = 300, - containerClassName, - tableClassName, - tableProps, - rowSelection, - resizableColumns, - columnVisibility, - onColumnToggle, - scopeId = null, - beforeTable, - bodyHeight = null, - onHeaderHeightChange, - keyboardShortcuts, - expandable, - tableRef, - disableInteractiveClickGuard = false, -}: InfiniteVirtualTableInnerProps) => { - const generatedScopeId = useId() - const resolvedScopeId = useMemo( - () => scopeId ?? `ivt-${generatedScopeId}`, - [generatedScopeId, scopeId], - ) - const containerRef = useRef(null) - const visibilityRootRef = useRef(null) - const columnDomRefs = useRef< - Map - >(new Map()) - const containerSize = useContainerResize(containerRef) - const [tableHeaderHeight, setTableHeaderHeight] = useState(null) - const lastScrollConfigRef = useRef | null>(null) - const visibilityStorageKey = columnVisibility?.storageKey - const visibilityDefaultHiddenKeys = columnVisibility?.defaultHiddenKeys - const normalizedDefaultHiddenKeys = useMemo( - () => visibilityDefaultHiddenKeys?.map((key) => String(key)), - [visibilityDefaultHiddenKeys], - ) - const handleVisibilityStateChange = columnVisibility?.onStateChange - const handleVisibilityContextChange = columnVisibility?.onContextChange - const handleViewportVisibilityChange = columnVisibility?.onViewportVisibilityChange - const baseTrackingEnabled = - columnVisibility?.viewportTrackingEnabled === undefined - ? true - : columnVisibility.viewportTrackingEnabled - - useEffect(() => { - if (!onHeaderHeightChange) return - onHeaderHeightChange(tableHeaderHeight) - }, [onHeaderHeightChange, tableHeaderHeight]) - - // Use extracted hook for infinite scroll handling - const handleScroll = useInfiniteScroll({loadMore, scrollThreshold}) - - const scrollX = containerSize.width - const scrollY = containerSize.height - - const resizable = typeof resizableColumns === "object" ? resizableColumns : undefined - const resizableEnabled = Boolean(resizableColumns) - - const columnVisibilityResult = useColumnVisibility(columns, { - storageKey: visibilityStorageKey, - defaultHiddenKeys: normalizedDefaultHiddenKeys, - }) - const {visibleColumns, version} = columnVisibilityResult - const columnVisibilityControls = - useColumnVisibilityControlsBuilder(columnVisibilityResult) - const lastReportedVersionRef = useRef(null) - - // Calculate selection column width before using resizable columns hook - const selectionColumnWidth = rowSelection ? (rowSelection.columnWidth ?? 48) : 0 - - const { - columns: resizableProcessedColumns, - headerComponents: resizableHeaderComponents, - getTotalWidth, - isResizing, - } = useSmartResizableColumns({ - columns: visibleColumns, - enabled: resizableEnabled, - minWidth: resizable?.minWidth, - scopeId: resolvedScopeId, - containerWidth: scrollX > 0 ? scrollX : 1200, // fallback to 1200 if no width yet - selectionColumnWidth, - }) - const visibilityTrackingEnabled = baseTrackingEnabled && active - - const stickyColumnKeys = useMemo( - () => collectFixedColumnKeys(resizableProcessedColumns), - [resizableProcessedColumns], - ) - - const finalColumns = resizableProcessedColumns - const columnDescendantMap = useMemo( - () => buildColumnDescendantMap(resizableProcessedColumns), - [resizableProcessedColumns], - ) - const internalViewportVisibilityHandler = useSetAtom(setColumnViewportVisibilityAtom) - const internalViewportVisibilityDeleteHandler = useSetAtom(deleteColumnViewportVisibilityAtom) - const internalUserVisibilityHandler = useSetAtom(setColumnUserVisibilityAtom) - const viewportVisibilityHandler = - handleViewportVisibilityChange ?? internalViewportVisibilityHandler - const _userVisibilityHandler = onColumnToggle ?? internalUserVisibilityHandler - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - columnDomRefs.current = new Map() - return - } - const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), - ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) - if (!headerCells.length) { - columnDomRefs.current = new Map() - return - } - - const keyToIndices = new Map() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - const index = cell.cellIndex - if (index < 0) return - if (!keyToIndices.has(key)) { - keyToIndices.set(key, []) - } - keyToIndices.get(key)!.push(index) - }) - - const registry = new Map< - string, - {cols: HTMLTableColElement[]; headers: HTMLTableCellElement[]} - >() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.headers.push(cell) - }) - - const tables = container.querySelectorAll(".ant-table table") - tables.forEach((table) => { - const cols = table.querySelectorAll("colgroup col") - keyToIndices.forEach((indices, key) => { - indices.forEach((idx) => { - const col = cols[idx] - if (!col) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.cols.push(col) - }) - }) - }) - - columnDomRefs.current = registry - }, [resizableProcessedColumns]) - - const registerHeaderForVisibility = useHeaderViewportVisibility({ - scopeId: resolvedScopeId, - containerRef: visibilityRootRef, - onVisibilityChange: viewportVisibilityHandler, - onColumnUnregister: internalViewportVisibilityDeleteHandler, - enabled: visibilityTrackingEnabled, - suspendUpdates: isResizing, - viewportMargin: columnVisibility?.viewportMargin, - exitDebounceMs: columnVisibility?.viewportExitDebounceMs, - excludeKeys: stickyColumnKeys, - descendantColumnMap: columnDescendantMap, - }) - - const visibilityHandlersRef = useRef(new Map void>()) - - useEffect(() => { - visibilityHandlersRef.current.clear() - }, [registerHeaderForVisibility]) - - const registerHeaderNode = useCallback( - (columnKey: string, node: HTMLElement | null) => { - if (!registerHeaderForVisibility) return - const cache = visibilityHandlersRef.current - let handler = cache.get(columnKey) - if (!handler) { - handler = registerHeaderForVisibility(columnKey) - cache.set(columnKey, handler) - } - handler(node) - }, - [registerHeaderForVisibility], - ) - - const visibilityRegistration = registerHeaderForVisibility ? registerHeaderNode : null - const lastNotifiedContextRef = useRef<{ - version: number - register: VisibilityRegistrationHandler | null - } | null>(null) - - useEffect(() => { - if (handleVisibilityStateChange && columnVisibilityControls) { - if (lastReportedVersionRef.current !== version) { - lastReportedVersionRef.current = version - handleVisibilityStateChange(columnVisibilityControls) - } - } - if (handleVisibilityContextChange && columnVisibilityControls) { - const previous = lastNotifiedContextRef.current - const nextRegister = visibilityRegistration ?? null - const shouldNotify = - !previous || previous.version !== version || previous.register !== nextRegister - if (shouldNotify) { - lastNotifiedContextRef.current = { - version, - register: nextRegister, - } - handleVisibilityContextChange({ - controls: columnVisibilityControls, - registerHeader: nextRegister, - version, - }) - } - } - }, [ - columnVisibilityControls, - handleVisibilityContextChange, - handleVisibilityStateChange, - visibilityRegistration, - version, - ]) - - // Ensure the Ant Design selection column (checkbox column) keeps the configured - // width, even when using resizable columns and fixed headers. AntD renders the - // selection column via col.ant-table-selection-col and th.ant-table-selection-column, - // which are not part of our normal column tree, so we adjust them directly. - useLayoutEffect(() => { - if (!rowSelection) return - if (!selectionColumnWidth || !Number.isFinite(selectionColumnWidth)) return - - const container = containerRef.current - if (!container) return - - const widthPx = `${selectionColumnWidth}px` - - const tables = container.querySelectorAll(".ant-table table") - tables.forEach((table) => { - const selectionCol = table.querySelector( - "colgroup col.ant-table-selection-col", - ) - if (selectionCol) { - selectionCol.style.width = widthPx - selectionCol.style.minWidth = widthPx - selectionCol.style.maxWidth = widthPx - } - }) - - const headerCells = container.querySelectorAll( - ".ant-table-thead th.ant-table-selection-column", - ) - headerCells.forEach((cell) => { - cell.style.width = widthPx - cell.style.minWidth = widthPx - cell.style.maxWidth = widthPx - }) - }, [rowSelection, selectionColumnWidth, resizableProcessedColumns]) - - const computedTotalWidth = useMemo( - () => getTotalWidth(finalColumns), - [finalColumns, getTotalWidth], - ) - const computedScrollX = computedTotalWidth + selectionColumnWidth - - const resolvedTableProps = useMemo>( - () => tableProps ?? ({} as TableProps), - [tableProps], - ) - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - setTableHeaderHeight(null) - return - } - const headerEl = - container.querySelector(".ant-table-thead") ?? - container.querySelector("table thead") - if (!headerEl) { - setTableHeaderHeight(null) - return - } - let frameId: number | null = null - const updateHeight = () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - frameId = requestAnimationFrame(() => { - frameId = null - const nextHeight = headerEl.getBoundingClientRect().height - setTableHeaderHeight((prev) => { - if (prev === nextHeight) return prev - return Number.isFinite(nextHeight) ? nextHeight : prev - }) - }) - } - const observer = new ResizeObserver(() => updateHeight()) - observer.observe(headerEl) - updateHeight() - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - observer.disconnect() - } - }, []) - - const scrollConfig = useMemo(() => { - if (typeof bodyHeight === "number" && Number.isFinite(bodyHeight)) { - const resolvedScroll = resolvedTableProps.scroll - const resolvedX = - resolvedScroll && typeof resolvedScroll.x !== "undefined" - ? resolvedScroll.x - : scrollX > 0 - ? scrollX - : undefined - return {x: resolvedX, y: bodyHeight} - } - const headerHeight = - (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) - ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) - ?.offsetHeight) ?? null - - const computedY = Math.max((scrollY ?? 0) - (headerHeight ?? 0), 0) - const resolvedScroll = resolvedTableProps.scroll - const requestedY = - resolvedScroll && typeof resolvedScroll.y === "number" ? resolvedScroll.y : undefined - const fallbackY = requestedY ?? computedY - let resolvedY = - typeof fallbackY === "number" && Number.isFinite(fallbackY) ? fallbackY : undefined - const resolvedX = (() => { - const rawX = resolvedScroll?.x - if (typeof rawX === "number" || typeof rawX === "string") { - return rawX - } - const computed = - Number.isFinite(computedScrollX) && computedScrollX > 0 ? computedScrollX : 0 - const container = scrollX > 0 ? scrollX : 0 - - // Always use the larger of computed or container width - // The sum constraint is enforced in computeSmartWidths, - // so computed should always >= container - const maxWidth = Math.max(computed, container) - return maxWidth > 0 ? maxWidth : undefined - })() - - if (resolvedY === undefined || resolvedY <= 0) { - const measured = scrollY ?? 0 - resolvedY = measured > 0 ? Math.max(measured - (headerHeight ?? 0), 0) : 360 - } - - if (resolvedY <= 0) { - resolvedY = 360 - } - - const { - x: _ignoredX, - y: _ignoredY, - ...restScroll - } = (resolvedScroll ?? {}) as Record - const nextConfig = { - ...restScroll, - x: resolvedX, - y: resolvedY, - } - const previous = lastScrollConfigRef.current - if (shallowEqual(previous, nextConfig)) { - return previous! - } - lastScrollConfigRef.current = nextConfig - return nextConfig - }, [ - bodyHeight, - scrollX, - scrollY, - resolvedTableProps.scroll, - shallowEqual, - computedScrollX, - tableHeaderHeight, - ]) - - // Sync .ant-table-header scroll position with .ant-table-body on every horizontal scroll. - // - // AntD's virtual Table syncs header/body scroll internally, but it can lose sync after - // column visibility changes, resizes, or scroll-config updates that trigger a re-render. - // We attach our own passive scroll listener as a safety net: when it fires, the header is - // already correct (AntD's handler ran first), so this is a no-op in the happy path. - // When AntD's sync breaks, our listener corrects the header on the very next scroll tick. - useEffect(() => { - const container = containerRef.current - if (!container) return - - const body = container.querySelector(".ant-table-body") - const header = container.querySelector(".ant-table-header") - if (!body || !header) return - - const sync = () => { - if (header.scrollLeft !== body.scrollLeft) { - header.scrollLeft = body.scrollLeft - } - } - - body.addEventListener("scroll", sync, {passive: true}) - // Correct any drift that happened during the re-render that triggered this effect - sync() - - return () => { - body.removeEventListener("scroll", sync) - } - }, [finalColumns, scrollConfig.x]) - - // Memoize dependencies object to prevent unnecessary useEffect runs in useScrollContainer - // Without memoization, a new object is created every render, causing infinite loops during scroll - const scrollContainerDeps = useMemo( - () => ({ - scrollX: scrollConfig.x, - scrollY: scrollConfig.y, - className: resolvedTableProps.className, - }), - [scrollConfig.x, scrollConfig.y, resolvedTableProps.className], - ) - - const {scrollContainer, visibilityRoot} = useScrollContainer(containerRef, scrollContainerDeps) - - // Sync visibilityRootRef with visibilityRoot from hook - useEffect(() => { - visibilityRootRef.current = visibilityRoot ?? containerRef.current - }, [visibilityRoot]) - - const mergedComponents = useMemo(() => { - if (!resizableHeaderComponents) { - return resolvedTableProps.components - } - const existingHeader = resolvedTableProps.components?.header ?? {} - return { - ...resolvedTableProps.components, - header: { - ...existingHeader, - ...resizableHeaderComponents, - }, - } - }, [resolvedTableProps.components, resizableHeaderComponents]) - - const finalTableProps = useMemo>( - () => ({ - ...resolvedTableProps, - components: mergedComponents, - }), - [resolvedTableProps, mergedComponents], - ) - - const {getRowProps: getShortcutRowProps} = useTableKeyboardShortcuts({ - containerRef, - dataSource, - rowKey, - rowSelection, - keyboardShortcuts, - active, - }) - - const mergedOnRow = useCallback( - (record: RecordType, index: number) => { - const baseOnRow = finalTableProps.onRow - const baseProps = baseOnRow ? baseOnRow(record, index) : {} - const shortcutProps = getShortcutRowProps - ? (getShortcutRowProps(record, index) ?? {}) - : {} - - const baseOnClick = baseProps?.onClick - const guardedOnClick = - !disableInteractiveClickGuard && baseOnClick - ? (event: React.MouseEvent) => { - if (shouldIgnoreRowClick(event)) return - baseOnClick(event) - } - : baseOnClick - - const hasShortcuts = shortcutProps && Object.keys(shortcutProps).length > 0 - if (!hasShortcuts) { - if (guardedOnClick === baseOnClick) return baseProps - return {...baseProps, onClick: guardedOnClick} - } - return { - ...baseProps, - ...shortcutProps, - className: clsx(baseProps?.className, shortcutProps?.className), - onMouseEnter: mergeHandlers(baseProps?.onMouseEnter, shortcutProps?.onMouseEnter), - onClick: guardedOnClick, - } - }, - [finalTableProps.onRow, getShortcutRowProps, disableInteractiveClickGuard], - ) - - const tablePropsWithShortcuts = useMemo>(() => { - const needsMerge = - getShortcutRowProps || (Boolean(finalTableProps.onRow) && !disableInteractiveClickGuard) - if (!needsMerge) { - return finalTableProps - } - return { - ...finalTableProps, - onRow: mergedOnRow, - } - }, [finalTableProps, getShortcutRowProps, mergedOnRow, disableInteractiveClickGuard]) - - const tableRowSelection = useTableRowSelection(rowSelection) - - // Expandable rows support - const expandableConfig = useExpandableRows({ - config: expandable, - rowKey, - }) - - // Build expandable prop for Ant Design Table - const tableExpandable = useMemo(() => { - if (!expandable) return undefined - return { - expandedRowKeys: expandableConfig.expandedRowKeys, - onExpand: expandableConfig.onExpand, - expandedRowRender: expandableConfig.expandedRowRender, - expandIcon: expandableConfig.expandIcon, - rowExpandable: expandableConfig.rowExpandable, - columnWidth: expandableConfig.expandColumnWidth, - fixed: expandableConfig.expandFixed, - } - }, [expandable, expandableConfig]) - - const columnVisibilityVersion = version - - useEffect(() => { - const key = resolvedScopeId - if (!key) return undefined - const nextCount = (scopeUsageCounts.get(key) ?? 0) + 1 - scopeUsageCounts.set(key, nextCount) - if (nextCount > 1 && process.env.NODE_ENV !== "production") { - console.warn( - `[InfiniteVirtualTable] Duplicate scopeId "${key}" detected. Column visibility state will be shared across tables.`, - ) - } - return () => { - const current = scopeUsageCounts.get(key) ?? 0 - if (current <= 1) { - scopeUsageCounts.delete(key) - } else { - scopeUsageCounts.set(key, current - 1) - } - } - }, [resolvedScopeId]) - - return ( - - - controls={columnVisibilityControls} - registerHeader={visibilityRegistration} - version={columnVisibilityVersion} - renderMenuContent={columnVisibility?.renderMenuContent} - renderMenuTrigger={columnVisibility?.renderMenuTrigger} - scopeId={resolvedScopeId} - > - - {beforeTable} -
- - ref={tableRef as React.Ref} - className={tableClassName} - columns={finalColumns} - dataSource={dataSource} - rowKey={rowKey} - pagination={false} - onScroll={handleScroll} - rowSelection={tableRowSelection} - expandable={tableExpandable} - {...tablePropsWithShortcuts} - scroll={{ - x: scrollConfig.x, - y: scrollConfig.y, - }} - virtual - /> -
-
- -
- ) -} - -// Memoize the inner component to create a render boundary -// This prevents re-renders when parent re-renders with referentially equal props -const InfiniteVirtualTableInner = memo( - InfiniteVirtualTableInnerBase, -) as typeof InfiniteVirtualTableInnerBase - -export default InfiniteVirtualTableInner diff --git a/web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx b/web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx deleted file mode 100644 index f65daaaf17..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/TableDescription.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type {ReactNode} from "react" - -import {Typography} from "antd" -import clsx from "clsx" - -export interface TableDescriptionProps { - /** The description text or content */ - children: ReactNode - /** Additional CSS class names */ - className?: string - /** Maximum width constraint (default: "prose" for readable line length) */ - maxWidth?: "prose" | "full" | "none" -} - -/** - * A reusable description component for table headers. - * Provides consistent styling and can be enhanced with additional functionality. - * - * @example - * ```tsx - * - * Manage your testsets for evaluations. - * - * - * - * Specify column names similar to the Input parameters. - * A column with 'correct_answer' name will be treated as a ground truth column. - * - * ``` - */ -const TableDescription = ({children, className, maxWidth = "prose"}: TableDescriptionProps) => { - const maxWidthClass = { - prose: "max-w-prose", - full: "max-w-full", - none: "", - }[maxWidth] - - return ( - - {children} - - ) -} - -export default TableDescription diff --git a/web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx b/web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx deleted file mode 100644 index 98a5b62b9f..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/TableShell.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type {ReactNode} from "react" -import {useLayoutEffect, useRef} from "react" - -import clsx from "clsx" - -interface TableShellProps { - title?: ReactNode - description?: ReactNode - badge?: ReactNode - header?: ReactNode - /** Additional content to render in the header row (e.g., tabs) */ - headerExtra?: ReactNode - filters?: ReactNode - primaryActions?: ReactNode - secondaryActions?: ReactNode - className?: string - contentClassName?: string - onHeaderHeightChange?: (height: number) => void - children: ReactNode -} - -const TableShell = ({ - title, - description, - badge, - header, - headerExtra, - filters, - primaryActions, - secondaryActions, - className, - contentClassName, - onHeaderHeightChange, - children, -}: TableShellProps) => { - const headerRef = useRef(null) - const lastHeightRef = useRef(0) - - useLayoutEffect(() => { - if (!onHeaderHeightChange) return - const element = headerRef.current - if (!element) { - if (lastHeightRef.current !== 0) { - lastHeightRef.current = 0 - onHeaderHeightChange(0) - } - return - } - const update = () => { - const nextHeight = element.getBoundingClientRect().height - // Only call callback if height actually changed - // This prevents infinite loops during horizontal scroll - if (lastHeightRef.current !== nextHeight) { - lastHeightRef.current = nextHeight - onHeaderHeightChange(nextHeight) - } - } - update() - const observer = new ResizeObserver(() => update()) - observer.observe(element) - return () => observer.disconnect() - }, [onHeaderHeightChange]) - - const renderDefaultHeader = () => ( -
- {title || headerExtra || (!filters && (primaryActions || secondaryActions)) ? ( -
- {title ? ( -
-
{title}
- {badge} -
- ) : ( -
- )} - -
- {headerExtra} - {!filters ? ( -
- {secondaryActions} - {primaryActions} -
- ) : null} -
-
- ) : null} - - {description ?
{description}
: null} - - {filters ? ( -
-
{filters}
-
- {secondaryActions} - {primaryActions} -
-
- ) : null} -
- ) - - const headerNode = header ?? renderDefaultHeader() - - return ( -
- {headerNode ? ( -
- {headerNode} -
- ) : null} -
{children}
-
- ) -} - -export default TableShell diff --git a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx b/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx deleted file mode 100644 index 793495f0ba..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityMenuTrigger.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import type {ReactNode} from "react" - -import {useColumnVisibilityContext} from "../../context/ColumnVisibilityContext" -import type {ColumnVisibilityState} from "../../types" -import ColumnVisibilityTrigger from "../ColumnVisibilityTrigger" - -import ColumnVisibilityPopoverContent, { - type ColumnVisibilityNodeMeta, - type ColumnVisibilityPopoverContentProps, -} from "./ColumnVisibilityPopoverContent" - -interface ColumnVisibilityMenuTriggerProps extends Omit< - ColumnVisibilityPopoverContentProps, - "onClose" -> { - variant?: "icon" | "button" - label?: string - controls?: ColumnVisibilityState - renderContent?: ( - controls: ColumnVisibilityState, - close: () => void, - context: {scopeId: string | null}, - ) => ReactNode -} - -const ColumnVisibilityMenuTrigger = ({ - variant = "button", - label = "Columns", - controls, - renderContent, - scopeId, - resolveNodeMeta, -}: ColumnVisibilityMenuTriggerProps) => { - const { - controls: fallbackControls, - renderMenuContent: contextRenderContent, - renderMenuTrigger: contextRenderTrigger, - scopeId: contextScopeId, - } = useColumnVisibilityContext() - const visibilityControls = controls ?? fallbackControls - const effectiveScopeId = scopeId ?? contextScopeId ?? null - - const contentRenderer = renderContent ?? contextRenderContent - - // If a custom trigger renderer is provided, use it instead of the default popover trigger - if (contextRenderTrigger) { - return <>{contextRenderTrigger(visibilityControls, {scopeId: effectiveScopeId})} - } - - return ( - - contentRenderer ? ( - contentRenderer(ctrls, close, {scopeId: effectiveScopeId}) - ) : ( - - ) - } - /> - ) -} - -export default ColumnVisibilityMenuTrigger - -export type {ColumnVisibilityNodeMeta} diff --git a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx b/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx deleted file mode 100644 index bca26ab2aa..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/ColumnVisibilityPopoverContent.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import {useCallback, useEffect, useMemo, useState} from "react" - -import {FolderOpenOutlined, FileOutlined} from "@ant-design/icons" -import {ArrowCounterClockwise} from "@phosphor-icons/react" -import {Button, Input, Space, Tree, Typography} from "antd" -import type {DataNode} from "antd/es/tree" -import {LOW_PRIORITY, useSetAtomWithSchedule} from "jotai-scheduler" - -import {getColumnWidthsAtom} from "../../atoms/columnWidths" -import {useColumnVisibilityControls, type ColumnVisibilityState} from "../../InfiniteVirtualTable" -import type { - ColumnTreeNode, - ColumnVisibilityNodeMeta, - ColumnVisibilityNodeMetaResolver, -} from "../../types" - -export interface ColumnVisibilityPopoverContentProps { - onClose: () => void - controls?: ColumnVisibilityState - scopeId?: string | null - resolveNodeMeta?: ColumnVisibilityNodeMetaResolver - onExport?: () => void - isExporting?: boolean - /** Additional content to render before the visibility controls */ - additionalContent?: React.ReactNode -} - -type VisibilityTreeNode = DataNode & {searchLabel: string} - -const ColumnVisibilityPopoverContent = ({ - onClose, - controls, - scopeId = null, - resolveNodeMeta, - onExport, - isExporting, - additionalContent, -}: ColumnVisibilityPopoverContentProps) => { - const fallbackControls = useColumnVisibilityControls() - const visibilityControls = controls ?? fallbackControls - const {columnTree, leafKeys, toggleColumn, toggleTree, reset, setHiddenKeys} = - visibilityControls - - const columnWidthsAtom = useMemo(() => getColumnWidthsAtom(scopeId), [scopeId]) - const setColumnWidths = useSetAtomWithSchedule(columnWidthsAtom, { - priority: LOW_PRIORITY, - }) - - const [search, setSearch] = useState("") - const allTreeKeys = useMemo(() => { - const keys: string[] = [] - const walk = (nodes: typeof columnTree) => { - nodes.forEach((node) => { - keys.push(String(node.key)) - if (node.children?.length) { - walk(node.children) - } - }) - } - walk(columnTree) - return keys - }, [columnTree]) - const [expandedKeys, setExpandedKeys] = useState(allTreeKeys) - - useEffect(() => { - setExpandedKeys(allTreeKeys) - }, [allTreeKeys]) - - const allNodes = useMemo(() => { - const nodes: ColumnTreeNode[] = [] - const walk = (items: typeof columnTree) => { - items.forEach((node) => { - nodes.push(node) - if (node.children?.length) { - walk(node.children) - } - }) - } - walk(columnTree) - return nodes - }, [columnTree]) - - const [resolvedNodeMetaMap, setResolvedNodeMetaMap] = useState( - () => new Map(), - ) - - useEffect(() => { - if (!resolveNodeMeta) { - setResolvedNodeMetaMap(new Map()) - return - } - let active = true - setResolvedNodeMetaMap(new Map()) - - allNodes.forEach((node) => { - const key = String(node.key) - Promise.resolve(resolveNodeMeta(node)).then((meta) => { - if (!active || !meta) return - setResolvedNodeMetaMap((prev) => { - const existing = prev.get(key) - if (existing === meta) return prev - const next = new Map(prev) - next.set(key, meta) - return next - }) - }) - }) - - return () => { - active = false - } - }, [allNodes, resolveNodeMeta]) - - const defaultNodeMeta = useCallback( - (node: ColumnTreeNode, hasChildren: boolean): ColumnVisibilityNodeMeta => { - const key = String(node.key) - const label = node.titleNode ?? node.label ?? key - return { - title: - typeof label === "string" ? ( - - {label} - - ) : ( - label - ), - searchValues: [typeof label === "string" ? label : undefined, key], - icon: hasChildren ? : , - } - }, - [], - ) - - const treeData = useMemo(() => { - const mapNodes = (nodes: typeof columnTree): VisibilityTreeNode[] => - nodes.map((node) => { - const hasChildren = Boolean(node.children?.length) - const key = String(node.key) - const customMeta = resolvedNodeMetaMap.get(key) - const defaultMeta = defaultNodeMeta(node, hasChildren) - const meta = customMeta ?? defaultMeta - const title = meta.title ?? defaultMeta.title - const icon = - meta.icon ?? - defaultMeta.icon ?? - (hasChildren ? : ) - const searchValues = meta.searchValues ?? - defaultMeta.searchValues ?? [ - node.label ?? undefined, - typeof node.key === "string" ? node.key : key, - ] - const searchLabel = searchValues - .filter((segment): segment is string => Boolean(segment)) - .join(" ") - - const children = hasChildren ? mapNodes(node.children) : undefined - - return { - key, - title, - icon, - children, - selectable: false, - searchLabel, - checked: node.checked, - indeterminate: node.indeterminate, - } as VisibilityTreeNode - }) - - return mapNodes(columnTree) - }, [columnTree, defaultNodeMeta, resolvedNodeMetaMap]) - - const filterTreeData = useCallback( - (nodes: VisibilityTreeNode[], query: string): VisibilityTreeNode[] => - nodes - .map((node) => { - const children = Array.isArray(node.children) - ? filterTreeData(node.children as VisibilityTreeNode[], query) - : undefined - const matches = node.searchLabel.toLowerCase().includes(query) - if (matches || (children && children.length)) { - return {...node, children} - } - return null - }) - .filter(Boolean) as VisibilityTreeNode[], - [], - ) - - const filteredTreeData = useMemo(() => { - const query = search.trim().toLowerCase() - if (!query) return treeData - return filterTreeData(treeData, query) - }, [filterTreeData, search, treeData]) - - const checkedKeys = useMemo(() => { - const keys: string[] = [] - const gather = (nodes: typeof columnTree) => { - nodes.forEach((node) => { - if (node.checked) keys.push(String(node.key)) - if (node.children?.length) gather(node.children) - }) - } - gather(columnTree) - return keys - }, [columnTree]) - - const halfCheckedKeys = useMemo(() => { - const keys: string[] = [] - const gather = (nodes: typeof columnTree) => { - nodes.forEach((node) => { - if (node.indeterminate) keys.push(String(node.key)) - if (node.children?.length) gather(node.children) - }) - } - gather(columnTree) - return keys - }, [columnTree]) - - const handleExpandAll = useCallback(() => { - setExpandedKeys(allTreeKeys) - }, [allTreeKeys]) - - const handleCollapseAll = useCallback(() => { - setExpandedKeys([]) - }, []) - - const handleShowAll = useCallback(() => { - setHiddenKeys([]) - }, [setHiddenKeys]) - - const handleHideAll = useCallback(() => { - setHiddenKeys(leafKeys) - }, [leafKeys, setHiddenKeys]) - - const handleResetLayout = useCallback(() => { - reset() - setColumnWidths(() => ({})) - setSearch("") - setExpandedKeys(allTreeKeys) - }, [allTreeKeys, reset, setColumnWidths]) - - return ( -
- {additionalContent} - - setSearch(event.target.value)} - /> - -
- - Visibility - - - - - - - -
-
-
- setExpandedKeys(keys as string[])} - treeData={filteredTreeData} - onCheck={(_, info) => { - const key = String(info.node.key) - const nodeItem = info.node as VisibilityTreeNode - const hasNestedChildren = - Array.isArray(nodeItem.children) && nodeItem.children.length > 0 - if (hasNestedChildren) { - toggleTree(key) - } else { - toggleColumn(key) - } - }} - /> -
-
- -
- - -
-
- ) -} - -export default ColumnVisibilityPopoverContent - -export type {ColumnVisibilityNodeMeta, ColumnVisibilityNodeMetaResolver} diff --git a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx b/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx deleted file mode 100644 index f8fb6e81f3..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/components/columnVisibility/TableSettingsDropdown.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import {type ReactNode, useState, useMemo, useCallback} from "react" - -import {DownloadSimple, Eye, GearSix, Trash} from "@phosphor-icons/react" -import {Button, Dropdown, Popover, Tooltip} from "antd" -import type {MenuProps} from "antd" - -import type {ColumnVisibilityState} from "../../types" - -export interface TableSettingsDropdownProps { - controls: ColumnVisibilityState - onExport?: () => void - isExporting?: boolean - onDelete?: () => void - deleteDisabled?: boolean - deleteLabel?: string - renderColumnVisibilityContent: ( - controls: ColumnVisibilityState, - close: () => void, - ) => ReactNode - /** Additional menu items to render after Column visibility */ - additionalMenuItems?: MenuProps["items"] -} - -/** - * A dropdown menu triggered by a gear icon that provides table settings actions. - * Opens a dropdown with options like "Export" and "Column Visibility". - * Column visibility opens a nested popover with the full column visibility UI. - */ -const TableSettingsDropdown = ({ - controls, - onExport, - isExporting, - onDelete, - deleteDisabled, - deleteLabel = "Delete", - renderColumnVisibilityContent, - additionalMenuItems, -}: TableSettingsDropdownProps) => { - const [dropdownOpen, setDropdownOpen] = useState(false) - const [columnVisibilityOpen, setColumnVisibilityOpen] = useState(false) - - const handleCloseColumnVisibility = useCallback(() => { - setColumnVisibilityOpen(false) - }, []) - - const handleOpenColumnVisibility = useCallback(() => { - setDropdownOpen(false) - // Small delay to let dropdown close before opening popover - setTimeout(() => { - setColumnVisibilityOpen(true) - }, 100) - }, []) - - const menuItems = useMemo(() => { - const items: MenuProps["items"] = [] - - // Column Visibility option - items.push({ - key: "column-visibility", - label: "Column visibility", - icon: , - onClick: (e) => { - e.domEvent.stopPropagation() - handleOpenColumnVisibility() - }, - }) - - // Additional menu items (e.g., Row height) - if (additionalMenuItems?.length) { - items.push({type: "divider"}) - items.push(...additionalMenuItems) - } - - // Export option (if enabled) - if (onExport) { - items.push({type: "divider"}) - items.push({ - key: "export", - label: isExporting ? "Exporting..." : "Export to CSV", - icon: , - disabled: isExporting, - onClick: (e) => { - e.domEvent.stopPropagation() - onExport() - setDropdownOpen(false) - }, - }) - } - - // Delete option (if enabled) - if (onDelete) { - items.push({type: "divider"}) - items.push({ - key: "delete", - label: deleteLabel, - icon: , - disabled: deleteDisabled, - danger: true, - onClick: (e) => { - e.domEvent.stopPropagation() - onDelete() - setDropdownOpen(false) - }, - }) - } - - return items - }, [ - additionalMenuItems, - deleteDisabled, - deleteLabel, - handleOpenColumnVisibility, - isExporting, - onDelete, - onExport, - ]) - - return ( - { - if (!open) { - setColumnVisibilityOpen(false) - } - }} - content={renderColumnVisibilityContent(controls, handleCloseColumnVisibility)} - destroyOnHidden - > - { - // Don't open dropdown if column visibility popover is open - if (columnVisibilityOpen && open) return - setDropdownOpen(open) - }} - menu={{items: menuItems}} - styles={{ - root: { - minWidth: 180, - }, - }} - > - - - - ) -} - -export default FiltersPopoverTrigger diff --git a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts b/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts deleted file mode 100644 index 0babcf7ca2..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityContext.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {createContext, useContext} from "react" -import type {Key} from "react" - -import type {VisibilityRegistrationHandler} from "../components/ColumnVisibilityHeader" -import type { - ColumnVisibilityState, - ColumnVisibilityMenuRenderer, - ColumnVisibilityMenuTriggerRenderer, -} from "../types" - -const noop = () => undefined - -const defaultColumnVisibilityControls: ColumnVisibilityState = { - allKeys: [], - leafKeys: [], - hiddenKeys: [], - setHiddenKeys: (_keys: Key[]) => undefined, - isHidden: () => false, - showColumn: noop, - hideColumn: noop, - toggleColumn: noop, - toggleTree: noop, - reset: noop, - visibleColumns: [], - columnTree: [], - version: 0, -} - -export interface ColumnVisibilityContextValue { - controls: ColumnVisibilityState - registerHeader: VisibilityRegistrationHandler | null - version: number - renderMenuContent?: ColumnVisibilityMenuRenderer - renderMenuTrigger?: ColumnVisibilityMenuTriggerRenderer - scopeId: string | null -} - -export const defaultColumnVisibilityContextValue: ColumnVisibilityContextValue = { - controls: defaultColumnVisibilityControls, - registerHeader: null, - version: 0, - renderMenuContent: undefined, - renderMenuTrigger: undefined, - scopeId: null, -} - -const ColumnVisibilityContext = createContext( - defaultColumnVisibilityContextValue, -) - -export const useColumnVisibilityContext = () => - useContext(ColumnVisibilityContext) as ColumnVisibilityContextValue - -export const useColumnVisibilityControls = () => - useColumnVisibilityContext().controls - -export {defaultColumnVisibilityControls} - -export default ColumnVisibilityContext diff --git a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx b/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx deleted file mode 100644 index fba8025fb4..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/context/ColumnVisibilityFlagContext.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {createContext, useContext, useMemo, type PropsWithChildren} from "react" - -import {IMMEDIATE_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" - -import { - // getScopedColumnVisibilityAtom, - scopedColumnVisibilityAtomFamily, -} from "../atoms/columnVisibility" - -interface ColumnVisibilityFlagContextValue { - scopeId: string | null -} - -const ColumnVisibilityFlagContext = createContext(null) - -export const ColumnVisibilityFlagProvider = ({ - scopeId, - children, -}: PropsWithChildren<{scopeId: string | null}>) => { - const value = useMemo(() => ({scopeId}), [scopeId]) - return ( - - {children} - - ) -} - -const useColumnVisibilityFlagContext = () => useContext(ColumnVisibilityFlagContext) - -export const useColumnVisibilityFlag = (columnKey?: string): boolean => { - const ctx = useColumnVisibilityFlagContext() - const scopeId = ctx?.scopeId ?? null - const visibilityAtom = useMemo( - () => scopedColumnVisibilityAtomFamily({scopeId, columnKey: columnKey ?? ""}), - [scopeId, columnKey], - ) - // Use IMMEDIATE_PRIORITY to ensure visibility updates don't lag behind scroll - // but still allow batching with other updates - const isVisible = - useAtomValueWithSchedule(visibilityAtom, {priority: IMMEDIATE_PRIORITY}) ?? false - - return isVisible -} - -export default ColumnVisibilityFlagContext diff --git a/web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts b/web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts deleted file mode 100644 index b695ca6ae7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/context/VirtualTableScrollContainerContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {createContext, useContext} from "react" - -const VirtualTableScrollContainerContext = createContext(null) - -export const useVirtualTableScrollContainer = () => useContext(VirtualTableScrollContainerContext) - -export default VirtualTableScrollContainerContext diff --git a/web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts b/web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts deleted file mode 100644 index e72b133da7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/createInfiniteDatasetStore.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type {Key} from "react" - -import type {Atom, PrimitiveAtom} from "jotai" -import {atom, useAtom, useAtomValue} from "jotai" -import {atomFamily} from "jotai/utils" - -import {createInfiniteTableStore} from "./createInfiniteTableStore" -import type {InfiniteTableStore} from "./createInfiniteTableStore" -import useInfiniteTablePagination from "./hooks/useInfiniteTablePagination" -import type {InfiniteTableFetchResult, InfiniteTableRowBase, WindowingState} from "./types" - -interface ScopeParams { - scopeId: string | null -} - -interface TablePagesParams { - scopeId: string | null - pageSize: number -} - -export interface InfiniteDatasetStoreConfig { - key: string - metaAtom: Atom - createSkeletonRow: (params: { - scopeId: string | null - offset: number - index: number - windowing: WindowingState | null - rowKey: string - }) => Row - mergeRow: (params: {skeleton: Row; apiRow?: ApiRow}) => Row - fetchPage: (params: { - meta: Meta - limit: number - offset: number - cursor: string | null - windowing: WindowingState | null - }) => Promise> - isEnabled?: (meta: Meta | undefined) => boolean - /** - * Optional atom that provides client-side rows (e.g., unsaved drafts) - * These rows will be prepended to server rows - */ - clientRowsAtom?: Atom - /** - * Optional atom providing IDs of rows to exclude from display - * Useful for filtering out soft-deleted rows before save - */ - excludeRowIdsAtom?: Atom> -} - -export interface InfiniteDatasetStore { - store: InfiniteTableStore - config: InfiniteDatasetStoreConfig - atoms: { - rowsAtom: (params: TablePagesParams) => Atom - paginationAtom: (params: TablePagesParams) => Atom<{ - hasMore: boolean - nextCursor: string | null - nextOffset: number | null - isFetching: boolean - totalCount: number | null - nextWindowing: WindowingState | null - }> - selectionAtom: (params: ScopeParams) => PrimitiveAtom - } - hooks: { - usePagination: (params: { - scopeId: string | null - pageSize: number - resetOnScopeChange?: boolean - }) => ReturnType> - useRowSelection: ( - params: ScopeParams, - ) => [Key[], (next: Key[] | ((prev: Key[]) => Key[])) => void] - } -} - -export const createInfiniteDatasetStore = ( - config: InfiniteDatasetStoreConfig, -): InfiniteDatasetStore => { - const selectionAtomFamily = atomFamily( - ({scopeId}: ScopeParams) => atom([]), - (a, b) => a.scopeId === b.scopeId, - ) - - const tableStore = createInfiniteTableStore({ - key: config.key, - createSkeletonRow: config.createSkeletonRow, - mergeRow: config.mergeRow, - getQueryMeta: ({get}) => get(config.metaAtom), - isEnabled: ({meta}) => { - if (config.isEnabled) { - return config.isEnabled(meta) - } - return Boolean(meta) - }, - fetchPage: async ({limit, offset, cursor, windowing, meta}) => { - if (!meta) { - return { - rows: [], - totalCount: 0, - hasMore: false, - nextOffset: null, - nextCursor: null, - nextWindowing: null, - } - } - - return config.fetchPage({ - meta, - limit, - offset, - cursor, - windowing, - }) - }, - }) - - // Create custom pagination hook that uses wrapped atoms (with client rows) - const usePagination = ({ - scopeId, - pageSize, - resetOnScopeChange, - }: { - scopeId: string | null - pageSize: number - resetOnScopeChange?: boolean - }) => { - // Get the base pagination result from tableStore - const basePagination = useInfiniteTablePagination({ - store: tableStore, - scopeId, - pageSize, - resetOnScopeChange, - }) - - // Always get wrapped atoms (even if not using them - to satisfy rules of hooks) - const wrappedRowsAtom = rowsWithClientAtomFamily({scopeId, pageSize}) - const wrappedPaginationAtom = paginationWithClientAtomFamily({scopeId, pageSize}) - - // Always read from wrapped atoms (rules of hooks) - const wrappedRows = useAtomValue(wrappedRowsAtom) as Row[] - const wrappedPaginationInfo = useAtomValue(wrappedPaginationAtom) - - // If no client rows, return base pagination as-is - if (!config.clientRowsAtom) { - return basePagination - } - - // Override with wrapped data - return { - ...basePagination, - rows: wrappedRows, - rowsAtom: wrappedRowsAtom, - totalRows: wrappedPaginationInfo.totalCount || 0, - paginationInfo: wrappedPaginationInfo, - } - } - - const useRowSelection = ({scopeId}: ScopeParams) => useAtom(selectionAtomFamily({scopeId})) - - // Create wrapper atoms that merge client rows if clientRowsAtom is provided - // Use atomFamily to cache derived atoms by params - const rowsWithClientAtomFamily = atomFamily( - (params: TablePagesParams) => { - const baseRowsAtom = tableStore.atoms.combinedRowsAtomFamily(params) - - return atom((get) => { - let baseRows = get(baseRowsAtom) - - // Apply exclusion filter if provided (e.g., filter out soft-deleted rows) - if (config.excludeRowIdsAtom) { - const excludeIds = get(config.excludeRowIdsAtom) - baseRows = baseRows.filter((row) => { - const rowId = - (typeof row.id === "string" || typeof row.id === "number" - ? String(row.id) - : null) ?? String(row.key) - return !excludeIds.has(rowId) - }) - } - - // Guard: only read from clientRowsAtom if it exists - if (!config.clientRowsAtom) { - return baseRows - } - - const clientRows = get(config.clientRowsAtom) - - // Prepend client rows to server rows - return [...clientRows, ...baseRows] - }) - }, - (a, b) => a.scopeId === b.scopeId && a.pageSize === b.pageSize, - ) - - const paginationWithClientAtomFamily = atomFamily( - (params: TablePagesParams) => { - const basePaginationAtom = tableStore.atoms.paginationInfoAtomFamily(params) - const baseRowsAtom = tableStore.atoms.combinedRowsAtomFamily(params) - - return atom((get) => { - const basePagination = get(basePaginationAtom) - - // Calculate actual count after filtering excluded rows - let serverRowCount = basePagination.totalCount || 0 - if (config.excludeRowIdsAtom) { - const excludeIds = get(config.excludeRowIdsAtom) - const baseRows = get(baseRowsAtom) - serverRowCount = baseRows.filter((row) => { - const rowId = - (typeof row.id === "string" || typeof row.id === "number" - ? String(row.id) - : null) ?? String(row.key) - return !excludeIds.has(rowId) - }).length - } - - // Guard: only read from clientRowsAtom if it exists - if (!config.clientRowsAtom) { - return { - ...basePagination, - totalCount: serverRowCount, - } - } - - const clientRows = get(config.clientRowsAtom) - - return { - ...basePagination, - totalCount: serverRowCount + clientRows.length, - } - }) - }, - (a, b) => a.scopeId === b.scopeId && a.pageSize === b.pageSize, - ) - - const rowsAtomGetter = (params: TablePagesParams) => { - if (!config.clientRowsAtom) { - return tableStore.atoms.combinedRowsAtomFamily(params) - } - return rowsWithClientAtomFamily(params) - } - - const paginationAtomGetter = (params: TablePagesParams) => { - if (!config.clientRowsAtom) { - return tableStore.atoms.paginationInfoAtomFamily(params) - } - return paginationWithClientAtomFamily(params) - } - - return { - store: tableStore, - config, - atoms: { - rowsAtom: rowsAtomGetter, - paginationAtom: paginationAtomGetter, - selectionAtom: (params) => selectionAtomFamily(params), - }, - hooks: { - usePagination, - useRowSelection, - }, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts b/web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts deleted file mode 100644 index 42238b3d5a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/createInfiniteTableStore.ts +++ /dev/null @@ -1,370 +0,0 @@ -import {atom} from "jotai" -import type {Atom, WritableAtom} from "jotai" -import {atomFamily} from "jotai/utils" -import {atomWithQuery} from "jotai-tanstack-query" -import type {AtomWithQueryResult} from "jotai-tanstack-query" - -import type { - InfiniteTableFetchParams, - InfiniteTableFetchResult, - InfiniteTablePage, - InfiniteTableRowBase, - WindowingState, -} from "./types" - -export interface TableRowAtomKey { - scopeId: string | null - offset: number - limit: number - cursor: string | null - windowing?: WindowingState | null -} - -export interface TablePagesKey { - scopeId: string | null - pageSize: number -} - -const createRandomId = () => { - const globalCrypto = typeof globalThis !== "undefined" ? (globalThis as any).crypto : undefined - if (globalCrypto?.randomUUID) { - return globalCrypto.randomUUID() - } - return `ivt-row-${Math.random().toString(36).slice(2)}` -} - -type PagesWriteArg = - | {pages: InfiniteTablePage[]} - | ((prev: {pages: InfiniteTablePage[]}) => { - pages: InfiniteTablePage[] - }) - -type ScheduleWriteArg = null | { - nextCursor: string - nextOffset: number - nextWindowing: WindowingState | null - totalRows: number -} - -export interface InfiniteTableStore { - key: string - atoms: { - pagesAtomFamily: ( - params: TablePagesKey, - ) => WritableAtom<{pages: InfiniteTablePage[]}, [PagesWriteArg], void> - scheduleNextPageAtomFamily: ( - params: TablePagesKey, - ) => WritableAtom - combinedRowsAtomFamily: (params: TablePagesKey) => Atom - paginationInfoAtomFamily: (params: TablePagesKey) => Atom<{ - hasMore: boolean - nextCursor: string | null - nextOffset: number | null - isFetching: boolean - totalCount: number | null - nextWindowing: WindowingState | null - }> - rowsAtomFamily: (params: TableRowAtomKey) => Atom - rowsQueryAtomFamily: ( - params: TableRowAtomKey, - ) => WritableAtom>, [], void> - } - createInitialPage: (pageSize: number) => InfiniteTablePage -} - -interface CreateInfiniteTableStoreOptions< - TableRow extends InfiniteTableRowBase, - ApiRow, - TMeta = unknown, -> { - key: string - createSkeletonRow: (params: { - scopeId: string | null - offset: number - index: number - windowing: WindowingState | null - rowKey: string - }) => TableRow - mergeRow: (params: {skeleton: TableRow; apiRow?: ApiRow}) => TableRow - fetchPage: ( - params: InfiniteTableFetchParams, - ) => Promise> - getQueryMeta?: (params: { - scopeId: string | null - get: InfiniteTableFetchParams["get"] - }) => TMeta - isEnabled?: (params: {scopeId: string | null; meta: TMeta | undefined}) => boolean - keyEquals?: { - row?: (a: TableRowAtomKey, b: TableRowAtomKey) => boolean - page?: (a: TablePagesKey, b: TablePagesKey) => boolean - } - staleTime?: number - gcTime?: number -} - -export const createInfiniteTableStore = < - TableRow extends InfiniteTableRowBase, - ApiRow, - TMeta = unknown, ->( - options: CreateInfiniteTableStoreOptions, -): InfiniteTableStore => { - const skeletonRowsCache = new Map() - - const makeCacheKey = ({scopeId, offset, limit, cursor, windowing}: TableRowAtomKey) => - `${options.key}:${scopeId ?? "scope"}:${offset}:${limit}:${cursor ?? "start"}:$${ - windowing?.next ?? "" - }:${windowing?.stop ?? ""}` - - const ensureSkeletonRows = (key: TableRowAtomKey) => { - const cacheKey = makeCacheKey(key) - let rows = skeletonRowsCache.get(cacheKey) - if (!rows) { - rows = Array.from({length: key.limit}, (_, index) => - options.createSkeletonRow({ - scopeId: key.scopeId, - offset: key.offset, - index, - windowing: key.windowing ?? null, - rowKey: createRandomId(), - }), - ) - skeletonRowsCache.set(cacheKey, rows) - } - return rows - } - - const rowsKeyEquals = - options.keyEquals?.row ?? - ((a: TableRowAtomKey, b: TableRowAtomKey) => { - return ( - a.scopeId === b.scopeId && - a.offset === b.offset && - a.limit === b.limit && - a.cursor === b.cursor && - (a.windowing?.next ?? null) === (b.windowing?.next ?? null) && - (a.windowing?.stop ?? null) === (b.windowing?.stop ?? null) - ) - }) - - const pagesKeyEquals = - options.keyEquals?.page ?? - ((a: TablePagesKey, b: TablePagesKey) => { - return a.scopeId === b.scopeId && a.pageSize === b.pageSize - }) - - const tableRowsQueryAtomFamily = atomFamily( - (params: TableRowAtomKey) => - atomWithQuery>((get) => { - const meta = options.getQueryMeta?.({scopeId: params.scopeId, get}) - const metaKey = meta === undefined ? null : JSON.stringify(meta) - const enabled = options.isEnabled - ? options.isEnabled({scopeId: params.scopeId, meta}) - : Boolean(params.scopeId) - - return { - queryKey: [ - options.key, - params.scopeId, - params.cursor, - params.limit, - params.offset, - params.windowing?.next ?? null, - params.windowing?.stop ?? null, - metaKey, - ], - enabled, - staleTime: options.staleTime ?? 15_000, - gcTime: options.gcTime ?? 60_000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - queryFn: async () => { - return options.fetchPage({ - scopeId: params.scopeId, - cursor: params.cursor, - limit: params.limit, - offset: params.offset, - windowing: params.windowing ?? null, - meta, - get, - }) - }, - } - }), - rowsKeyEquals, - ) - - const tableSkeletonRowsAtomFamily = atomFamily( - (key: TableRowAtomKey) => - atom(() => { - return ensureSkeletonRows(key) - }), - rowsKeyEquals, - ) - - const tableRowsAtomFamily = atomFamily( - (key: TableRowAtomKey) => - atom((get) => { - const skeletonRows = get(tableSkeletonRowsAtomFamily(key)) - const query = get(tableRowsQueryAtomFamily(key)) - const apiRows = query.data?.rows - - if (!apiRows) { - return skeletonRows - } - - if (!apiRows.length) { - return [] - } - - return skeletonRows.slice(0, apiRows.length).map((skeleton, index) => { - const apiRow = apiRows[index] - return options.mergeRow({skeleton, apiRow}) - }) - }), - rowsKeyEquals, - ) - - const tablePagesAtomFamily = atomFamily(({scopeId, pageSize}: TablePagesKey) => { - const baseAtom = atom<{pages: InfiniteTablePage[]}>({ - pages: [ - { - offset: 0, - limit: pageSize, - cursor: null, - windowing: null, - }, - ], - }) - - return atom( - (get) => get(baseAtom), - ( - get, - set, - update: - | {pages: InfiniteTablePage[]} - | ((prev: {pages: InfiniteTablePage[]}) => {pages: InfiniteTablePage[]}), - ) => { - const nextValue = typeof update === "function" ? update(get(baseAtom)) : update - set(baseAtom, nextValue) - }, - ) - }, pagesKeyEquals) - - const tableCombinedRowsAtomFamily = atomFamily( - ({scopeId, pageSize}: TablePagesKey) => - atom((get) => { - const pagesState = get(tablePagesAtomFamily({scopeId, pageSize})) - const combined: TableRow[] = [] - pagesState.pages.forEach(({offset, limit, cursor, windowing}) => { - const rows = get( - tableRowsAtomFamily({scopeId, offset, limit, cursor, windowing}), - ) - combined.push(...rows) - }) - return combined - }), - pagesKeyEquals, - ) - - const tablePaginationInfoAtomFamily = atomFamily( - ({scopeId, pageSize}: TablePagesKey) => - atom((get) => { - const pagesState = get(tablePagesAtomFamily({scopeId, pageSize})) - const lastPage = pagesState.pages[pagesState.pages.length - 1] - if (!lastPage) { - return { - hasMore: false, - nextCursor: null as string | null, - nextOffset: null as number | null, - isFetching: false, - totalCount: null as number | null, - nextWindowing: null as WindowingState | null, - } - } - const query = get( - tableRowsQueryAtomFamily({ - scopeId, - cursor: lastPage.cursor, - limit: lastPage.limit, - offset: lastPage.offset, - windowing: lastPage.windowing ?? undefined, - }), - ) - const data = query.data - return { - hasMore: Boolean(data?.hasMore), - nextCursor: data?.nextCursor ?? null, - nextOffset: data?.nextOffset ?? null, - isFetching: Boolean(query.isFetching || query.isPending), - totalCount: data?.totalCount ?? null, - nextWindowing: data?.nextWindowing ?? null, - } - }), - pagesKeyEquals, - ) - - const createInitialPage = (pageSize: number): InfiniteTablePage => ({ - offset: 0, - limit: pageSize, - cursor: null, - windowing: null, - }) - - const tableScheduleNextPageAtomFamily = atomFamily( - ({scopeId, pageSize}: TablePagesKey) => - atom( - null, - ( - get, - set, - params: null | { - nextCursor: string - nextOffset: number - nextWindowing: WindowingState | null - totalRows: number - }, - ) => { - if (!params) return - set(tablePagesAtomFamily({scopeId, pageSize}), (prev) => { - if ( - prev.pages.some( - (page) => - page.cursor === params.nextCursor && - (page.windowing?.next ?? null) === - (params.nextWindowing?.next ?? params.nextCursor), - ) - ) { - return prev - } - return { - pages: [ - ...prev.pages, - { - offset: params.nextOffset, - limit: pageSize, - cursor: params.nextCursor, - windowing: params.nextWindowing, - }, - ], - } - }) - }, - ), - pagesKeyEquals, - ) - - return { - key: options.key, - atoms: { - pagesAtomFamily: tablePagesAtomFamily, - scheduleNextPageAtomFamily: tableScheduleNextPageAtomFamily, - combinedRowsAtomFamily: tableCombinedRowsAtomFamily, - paginationInfoAtomFamily: tablePaginationInfoAtomFamily, - rowsAtomFamily: tableRowsAtomFamily, - rowsQueryAtomFamily: tableRowsQueryAtomFamily, - }, - createInitialPage, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx b/web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx deleted file mode 100644 index a420759f92..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/features/InfiniteVirtualTableFeatureShell.tsx +++ /dev/null @@ -1,616 +0,0 @@ -import type {CSSProperties, Key, ReactNode} from "react" -import {useCallback, useEffect, useMemo, useState} from "react" - -import {TrashIcon} from "@phosphor-icons/react" -import {Button, Grid, Tabs, Tooltip} from "antd" -import type {MenuProps} from "antd" -import clsx from "clsx" - -import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" - -import ColumnVisibilityPopoverContent from "../components/columnVisibility/ColumnVisibilityPopoverContent" -import TableSettingsDropdown from "../components/columnVisibility/TableSettingsDropdown" -import TableShell from "../components/TableShell" -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import useTableExport, {type TableExportOptions} from "../hooks/useTableExport" -import InfiniteVirtualTable from "../InfiniteVirtualTable" -import type { - ColumnVisibilityMenuRenderer, - ColumnVisibilityState, - InfiniteTableRowBase, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, -} from "../types" - -type ColumnVisibilityRenderer = ( - controls: ColumnVisibilityState, - close: () => void, - context: {scopeId: string | null}, -) => ReactNode - -export interface TableScopeConfig { - scopeId: string | null - pageSize: number - enableInfiniteScroll?: boolean - columnVisibilityStorageKey?: string | null - columnVisibilityDefaults?: Key[] - viewportTrackingEnabled?: boolean - /** Margin around viewport for preloading columns (e.g., "0px 200px" to preload 200px on left/right) */ - viewportMargin?: string - /** Debounce time in ms before marking a column as hidden after it exits viewport (default: 150) */ - viewportExitDebounceMs?: number -} - -export interface TableFeaturePagination { - rows: Row[] - loadNextPage: () => void - resetPages: () => void -} - -export type TableFeatureExportOptions = TableExportOptions - -export interface TableTabItem { - key: string - label: string -} - -export interface TableTabsConfig { - /** Tab items to render */ - items: TableTabItem[] - /** Currently active tab key */ - activeKey: string - /** Callback when tab changes */ - onChange: (key: string) => void - /** Optional CSS variable for tab indicator color */ - indicatorColor?: string - /** Optional className for the tabs container */ - className?: string -} - -/** Configuration for the built-in delete action */ -export interface TableDeleteConfig { - /** Callback when delete is triggered */ - onDelete: () => void - /** Whether the delete action is disabled */ - disabled?: boolean - /** Tooltip to show when disabled */ - disabledTooltip?: string - /** Button label (default: "Delete") */ - label?: string -} - -/** Configuration for the built-in export action */ -export interface TableExportConfig { - /** Whether the export action is disabled */ - disabled?: boolean - /** Tooltip to show when disabled */ - disabledTooltip?: string - /** Button label (default: "Export CSV") */ - label?: string -} - -export interface InfiniteVirtualTableFeatureProps { - datasetStore: InfiniteDatasetStore - tableScope: TableScopeConfig - columns: InfiniteVirtualTableProps["columns"] - rowKey: InfiniteVirtualTableProps["rowKey"] - title?: ReactNode - /** Tabs configuration for the header */ - tabs?: TableTabsConfig - /** @deprecated Use tabs prop instead. Additional content to render in the header row */ - headerExtra?: ReactNode - filters?: ReactNode - primaryActions?: ReactNode - /** - * Built-in delete action configuration. - * When provided, the shell renders a standard delete button. - * On narrow screens, this moves to the settings dropdown. - */ - deleteAction?: TableDeleteConfig - /** - * Built-in export action configuration. - * When provided along with enableExport, the shell renders a standard export button. - * On narrow screens, export moves to the settings dropdown. - */ - exportAction?: TableExportConfig - /** @deprecated Use deleteAction instead. Custom secondary actions to render */ - secondaryActions?: ReactNode - className?: string - containerClassName?: string - tableClassName?: string - autoHeight?: boolean - rowHeight?: number - fallbackControlsHeight?: number - fallbackHeaderHeight?: number - resizableColumns?: InfiniteVirtualTableProps["resizableColumns"] - tableProps?: InfiniteVirtualTableProps["tableProps"] - beforeTable?: ReactNode - afterTable?: ReactNode - columnVisibilityMenuRenderer?: ColumnVisibilityMenuRenderer | ColumnVisibilityRenderer - columnVisibility?: InfiniteVirtualTableProps["columnVisibility"] - rowSelection?: InfiniteVirtualTableRowSelection - onPaginationStateChange?: (payload: {resetPages: () => void; loadNextPage: () => void}) => void - onRowsChange?: (rows: Row[]) => void - pagination?: TableFeaturePagination - enableExport?: boolean - exportFilename?: string - /** @deprecated Use exportAction instead for button customization */ - renderExportButton?: (props: {onExport: () => void; loading: boolean}) => ReactNode - exportOptions?: TableFeatureExportOptions - /** - * When true, the gear icon opens a dropdown menu with actions (Export, Column Visibility) - * instead of directly opening the column visibility popover. - * Default: false (gear icon opens column visibility popover directly) - */ - useSettingsDropdown?: boolean - /** - * @deprecated Use deleteAction instead. - * Delete action configuration for the settings dropdown. - * Only used when useSettingsDropdown is true. - */ - settingsDropdownDelete?: { - onDelete: () => void - disabled?: boolean - label?: string - } - /** - * Additional menu items for the settings dropdown. - * Only used when useSettingsDropdown is true. - */ - settingsDropdownMenuItems?: MenuProps["items"] - keyboardShortcuts?: InfiniteVirtualTableProps["keyboardShortcuts"] - /** - * Configuration for expandable rows. - * When provided, rows can be expanded to show child content (e.g., variants, revisions). - */ - expandable?: InfiniteVirtualTableProps["expandable"] - /** - * Override the dataSource from pagination. - * Useful when you need to transform rows (e.g., add children for tree data). - */ - dataSource?: Row[] - /** - * Jotai store to use for the table. When provided, the table will use this store - * instead of creating an isolated one. Useful when cells need to read from - * atoms in a shared store (e.g., entity atoms). - */ - store?: InfiniteVirtualTableProps["store"] - /** - * Ref to access the underlying Ant Design Table instance. - * Useful for programmatic scrolling via `tableRef.current?.scrollTo({ index })`. - */ - tableRef?: InfiniteVirtualTableProps["tableRef"] -} - -const DEFAULT_ROW_HEIGHT = 48 -const DEFAULT_CONTROLS_HEIGHT = 72 -const DEFAULT_TABLE_HEADER_HEIGHT = 48 - -interface ColumnVisibilityRendererContext { - scopeId: string | null - onExport?: () => void - isExporting?: boolean -} - -const resolveColumnVisibilityRenderer = ( - renderer: InfiniteVirtualTableFeatureProps["columnVisibilityMenuRenderer"], - config: InfiniteVirtualTableProps["columnVisibility"] | undefined, - context: ColumnVisibilityRendererContext, -): ColumnVisibilityMenuRenderer => { - const {scopeId, onExport, isExporting} = context - if (!renderer) { - return (controls, close) => ( - - ) - } - return (controls, close) => renderer(controls, close, {scopeId, onExport, isExporting}) -} - -function InfiniteVirtualTableFeatureShellBase( - props: InfiniteVirtualTableFeatureProps & {pagination: TableFeaturePagination}, -) { - const { - tableScope, - columns, - rowKey, - title, - tabs, - headerExtra, - filters, - primaryActions, - deleteAction, - exportAction, - secondaryActions, - className, - containerClassName, - tableClassName, - autoHeight = true, - rowHeight = DEFAULT_ROW_HEIGHT, - fallbackControlsHeight = DEFAULT_CONTROLS_HEIGHT, - fallbackHeaderHeight = DEFAULT_TABLE_HEADER_HEIGHT, - resizableColumns = true, - tableProps, - beforeTable, - afterTable, - columnVisibilityMenuRenderer, - columnVisibility, - rowSelection, - onPaginationStateChange, - onRowsChange, - pagination, - enableExport = true, - exportFilename, - renderExportButton, - exportOptions, - useSettingsDropdown = false, - settingsDropdownDelete, - settingsDropdownMenuItems, - keyboardShortcuts, - expandable, - dataSource, - tableRef, - store, - } = props - const {scopeId, pageSize, enableInfiniteScroll = true} = tableScope - const {canExportData} = useProjectPermissions() - const exportEnabled = enableExport && canExportData - - // Responsive breakpoints for built-in action buttons - const screens = Grid.useBreakpoint() - const isNarrowScreen = !screens.lg - - useEffect(() => { - onPaginationStateChange?.({ - resetPages: pagination.resetPages, - loadNextPage: pagination.loadNextPage, - }) - }, [onPaginationStateChange, pagination.loadNextPage, pagination.resetPages]) - - useEffect(() => { - onRowsChange?.(pagination.rows) - }, [onRowsChange, pagination.rows]) - - const handleLoadMore = useCallback(() => { - if (!enableInfiniteScroll) { - return - } - pagination.loadNextPage() - }, [enableInfiniteScroll, pagination.loadNextPage]) - - const [controlsHeight, setControlsHeight] = useState(0) - const [tableHeaderHeight, setTableHeaderHeight] = useState(null) - - const resolvedControlsHeight = controlsHeight || fallbackControlsHeight - const resolvedTableHeaderHeight = tableHeaderHeight ?? fallbackHeaderHeight - const visibleRowCount = pagination.rows.length || pageSize - const bodyHeight = autoHeight ? null : rowHeight * Math.max(visibleRowCount, 1) - const headerHeight = resolvedControlsHeight + resolvedTableHeaderHeight + 32 - const fixedHeight = !autoHeight && bodyHeight !== null ? bodyHeight + headerHeight : undefined - const resolvedContainerClassName = - containerClassName ?? - (autoHeight ? "w-full grow min-h-0 overflow-hidden" : "w-full overflow-hidden") - - const tableExport = useTableExport() - const [isExporting, setIsExporting] = useState(false) - const { - filename: exportOptionsFilename, - isColumnExportable, - getValue: getExportValue, - formatValue: formatExportValue, - includeSkeletonRows, - beforeExport, - resolveValue, - resolveColumnLabel, - columnsOverride: exportColumnsOverride, - } = exportOptions ?? {} - const resolvedExportFilename = exportOptionsFilename ?? exportFilename ?? "table-export.csv" - const exportHandler = useCallback(async () => { - if (!exportEnabled || isExporting) return - setIsExporting(true) - try { - // If rows are selected, export only selected rows; otherwise export all rows - const selectedKeys = rowSelection?.selectedRowKeys - const rowsToExport = - selectedKeys && selectedKeys.length > 0 - ? pagination.rows.filter((row) => { - const key = - typeof rowKey === "function" ? rowKey(row) : row[rowKey as keyof Row] - return selectedKeys.includes(key as Key) - }) - : pagination.rows - await tableExport({ - columns: exportColumnsOverride ?? columns, - rows: rowsToExport, - filename: resolvedExportFilename, - isColumnExportable, - getValue: getExportValue, - formatValue: formatExportValue, - includeSkeletonRows, - beforeExport, - resolveValue, - resolveColumnLabel, - }) - } catch (error) { - console.error("[InfiniteVirtualTable] Failed to export table", error) - } finally { - setIsExporting(false) - } - }, [ - beforeExport, - columns, - getExportValue, - formatExportValue, - includeSkeletonRows, - isExporting, - isColumnExportable, - pagination.rows, - resolveValue, - resolveColumnLabel, - resolvedExportFilename, - exportEnabled, - rowKey, - rowSelection?.selectedRowKeys, - tableExport, - ]) - - const exportButtonNode = useMemo(() => { - if (!exportEnabled) return null - if (renderExportButton) { - return renderExportButton({onExport: exportHandler, loading: isExporting}) - } - // Export button is now rendered inside the column visibility popover - return null - }, [exportEnabled, exportHandler, isExporting, renderExportButton]) - - // Built-in delete button (wide screens only) - const builtInDeleteButton = useMemo(() => { - if (!deleteAction || isNarrowScreen) return null - const {onDelete, disabled, disabledTooltip, label = "Delete"} = deleteAction - const button = ( - - ) - if (disabled && disabledTooltip) { - return {button} - } - return button - }, [deleteAction, isNarrowScreen]) - - // Built-in export button (wide screens only, when exportAction is provided) - const builtInExportButton = useMemo(() => { - if (!exportEnabled || !exportAction || isNarrowScreen) return null - const {disabled, disabledTooltip, label = "Export CSV"} = exportAction - const button = ( - - ) - if (disabled && disabledTooltip) { - return ( - - {button} - - ) - } - return button - }, [exportEnabled, exportAction, exportHandler, isExporting, isNarrowScreen]) - - // Resolve settings dropdown delete config (prefer deleteAction over legacy prop) - const resolvedSettingsDropdownDelete = useMemo(() => { - if (deleteAction && isNarrowScreen) { - return { - onDelete: deleteAction.onDelete, - disabled: deleteAction.disabled, - label: deleteAction.label ? `${deleteAction.label} selected` : "Delete selected", - } - } - return settingsDropdownDelete - }, [deleteAction, isNarrowScreen, settingsDropdownDelete]) - - // Combine secondary actions: built-in buttons + custom secondaryActions + export button - const resolvedSecondaryActions = useMemo(() => { - const actions = [ - builtInDeleteButton, - builtInExportButton, - secondaryActions, - exportButtonNode, - ] - const filtered = actions.filter(Boolean) - if (filtered.length === 0) return undefined - if (filtered.length === 1) return filtered[0] - return ( -
- {filtered.map((action, i) => ( - {action} - ))} -
- ) - }, [builtInDeleteButton, builtInExportButton, secondaryActions, exportButtonNode]) - - // Only show export in settings when enableExport is true AND no custom renderExportButton is provided - const showExportInSettings = exportEnabled && !renderExportButton - - const columnVisibilityRenderer = useMemo( - () => - resolveColumnVisibilityRenderer(columnVisibilityMenuRenderer, columnVisibility, { - scopeId, - onExport: showExportInSettings ? exportHandler : undefined, - isExporting, - }), - [ - columnVisibilityMenuRenderer, - columnVisibility, - scopeId, - showExportInSettings, - exportHandler, - isExporting, - ], - ) - - const viewportTrackingEnabled = useMemo( - () => - tableScope.viewportTrackingEnabled ?? pagination.rows.some((row) => !row.__isSkeleton), - [pagination.rows, tableScope.viewportTrackingEnabled], - ) - - const settingsDropdownRenderer = useCallback( - (controls: ColumnVisibilityState) => ( - - columnVisibilityRenderer(ctrls, close, { - scopeId, - onExport: showExportInSettings ? exportHandler : undefined, - isExporting, - }) - } - /> - ), - [ - columnVisibilityRenderer, - showExportInSettings, - exportHandler, - isExporting, - scopeId, - resolvedSettingsDropdownDelete, - settingsDropdownMenuItems, - ], - ) - - const columnVisibilityConfig = useMemo( - () => ({ - storageKey: tableScope.columnVisibilityStorageKey ?? undefined, - defaultHiddenKeys: tableScope.columnVisibilityDefaults, - viewportTrackingEnabled, - viewportMargin: tableScope.viewportMargin, - viewportExitDebounceMs: tableScope.viewportExitDebounceMs, - renderMenuContent: columnVisibilityRenderer, - renderMenuTrigger: useSettingsDropdown ? settingsDropdownRenderer : undefined, - }), - [ - columnVisibilityRenderer, - settingsDropdownRenderer, - tableScope.columnVisibilityDefaults, - tableScope.columnVisibilityStorageKey, - tableScope.viewportExitDebounceMs, - tableScope.viewportMargin, - useSettingsDropdown, - viewportTrackingEnabled, - ], - ) - - // Render tabs if configured - const tabsNode = useMemo(() => { - if (!tabs) return headerExtra // Fall back to headerExtra for backwards compatibility - return ( -
- ({ - key: item.key, - label: item.label, - }))} - onChange={tabs.onChange} - destroyOnHidden - /> -
- ) - }, [tabs, headerExtra]) - - const effectiveDataSource = dataSource ?? pagination.rows - - return ( -
- - {beforeTable} - - useIsolatedStore={!store} - store={store} - columns={columns} - dataSource={effectiveDataSource} - loadMore={handleLoadMore} - rowKey={rowKey} - rowSelection={rowSelection} - resizableColumns={resizableColumns} - columnVisibility={columnVisibilityConfig} - bodyHeight={bodyHeight} - scopeId={scopeId} - containerClassName={resolvedContainerClassName} - tableClassName={tableClassName} - tableProps={tableProps} - keyboardShortcuts={keyboardShortcuts} - expandable={expandable} - onHeaderHeightChange={setTableHeaderHeight} - tableRef={tableRef} - /> - {afterTable} - -
- ) -} - -const InfiniteVirtualTableFeatureShellWithStore = ( - props: InfiniteVirtualTableFeatureProps, -) => { - const {datasetStore, tableScope} = props - const pagination = datasetStore.hooks.usePagination({ - scopeId: tableScope.scopeId, - pageSize: tableScope.pageSize, - resetOnScopeChange: true, - }) - return -} - -const InfiniteVirtualTableFeatureShell = ( - props: InfiniteVirtualTableFeatureProps, -) => { - if (props.pagination) { - return - } - return -} - -export default InfiniteVirtualTableFeatureShell diff --git a/web/oss/src/components/InfiniteVirtualTable/features/index.ts b/web/oss/src/components/InfiniteVirtualTable/features/index.ts deleted file mode 100644 index b831036fe9..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/features/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export {default as InfiniteVirtualTableFeatureShell} from "./InfiniteVirtualTableFeatureShell" -export type { - InfiniteVirtualTableFeatureProps, - TableScopeConfig, - TableFeaturePagination, - TableFeatureExportOptions, - TableTabItem, - TableTabsConfig, - TableDeleteConfig, - TableExportConfig, -} from "./InfiniteVirtualTableFeatureShell" -export {default as useInfiniteTableFeaturePagination} from "./useInfiniteTableFeaturePagination" diff --git a/web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts b/web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts deleted file mode 100644 index 6075efc31f..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/features/useInfiniteTableFeaturePagination.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type {InfiniteTableRowBase} from "../types" - -import type {TableScopeConfig, TableFeaturePagination} from "./InfiniteVirtualTableFeatureShell" - -interface UseFeaturePaginationOptions { - resetOnScopeChange?: boolean -} - -const useInfiniteTableFeaturePagination = ( - datasetStore: InfiniteDatasetStore, - tableScope: TableScopeConfig, - options?: UseFeaturePaginationOptions, -): TableFeaturePagination => { - const {scopeId, pageSize} = tableScope - return datasetStore.hooks.usePagination({ - scopeId, - pageSize, - resetOnScopeChange: options?.resetOnScopeChange, - }) -} - -export default useInfiniteTableFeaturePagination diff --git a/web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts b/web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts deleted file mode 100644 index 3aa5893222..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/helpers/createSimpleTableStore.ts +++ /dev/null @@ -1,191 +0,0 @@ -import {atom} from "jotai" -import type {Atom} from "jotai" - -import {createInfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type {InfiniteTableFetchResult, InfiniteTableRowBase, WindowingState} from "../types" - -import {createTableRowHelpers} from "./createTableRowHelpers" -import type {TableRowHelpersConfig} from "./createTableRowHelpers" - -/** - * Common date range filter type used across tables - */ -export interface DateRangeFilter { - from?: string | null - to?: string | null -} - -/** - * Base interface for table metadata. - * All table stores should extend this with their specific filters. - */ -export interface BaseTableMeta { - /** Project ID - required for all tables */ - projectId: string | null - /** Search term for filtering */ - searchTerm?: string | null - /** Date range filter */ - dateRange?: DateRangeFilter | null - /** Internal refresh trigger - incrementing this forces a refetch */ - _refreshTrigger?: number -} - -/** - * Configuration for creating a simple table store - */ -export interface SimpleTableStoreConfig< - TRow extends InfiniteTableRowBase, - TApiRow, - TMeta extends BaseTableMeta, -> { - /** Unique key for the store (used for caching) */ - key: string - /** Atom that provides the table metadata */ - metaAtom: Atom - /** Configuration for row helpers (skeleton/merge) */ - rowHelpers: TableRowHelpersConfig - /** - * Fetch function that retrieves data from the API. - * Should handle pagination via limit/offset/cursor/windowing. - */ - fetchData: (params: { - meta: TMeta - limit: number - offset: number - cursor: string | null - windowing: WindowingState | null - }) => Promise> - /** - * Optional custom isEnabled check. - * Defaults to checking if projectId exists. - */ - isEnabled?: (meta: TMeta | undefined) => boolean - /** - * Optional atom that provides client-side rows (e.g., unsaved drafts) - * These rows will be prepended to server rows - */ - clientRowsAtom?: Atom - /** - * Optional atom providing IDs of rows to exclude from display - * Useful for filtering out soft-deleted rows before save - */ - excludeRowIdsAtom?: Atom> -} - -/** - * Result of createSimpleTableStore - */ -export interface SimpleTableStore< - TRow extends InfiniteTableRowBase, - TApiRow, - TMeta extends BaseTableMeta, -> { - /** The underlying infinite dataset store */ - datasetStore: InfiniteDatasetStore - /** Row helpers for creating skeletons and merging data */ - rowHelpers: ReturnType> - /** Refresh trigger atom - increment to force refetch */ - refreshTriggerAtom: ReturnType> -} - -/** - * Creates a simplified table store with common patterns pre-configured. - * Reduces boilerplate for standard paginated tables. - * - * @example - * ```ts - * const {datasetStore, refreshTriggerAtom} = createSimpleTableStore({ - * key: "testsets-table", - * metaAtom: testsetsTableMetaAtom, - * rowHelpers: { - * entityName: "testset", - * skeletonDefaults: {id: "", name: "", created_at: "", updated_at: ""}, - * getRowId: (row) => row.id, - * }, - * fetchData: async ({meta, limit, offset, cursor}) => { - * return fetchTestsetsWindow({projectId: meta.projectId, limit, offset, cursor}) - * }, - * }) - * ``` - */ -export function createSimpleTableStore< - TRow extends InfiniteTableRowBase, - TApiRow, - TMeta extends BaseTableMeta, ->(config: SimpleTableStoreConfig): SimpleTableStore { - const { - key, - metaAtom, - rowHelpers: rowHelpersConfig, - fetchData, - isEnabled, - clientRowsAtom, - excludeRowIdsAtom, - } = config - - // Create row helpers - const rowHelpers = createTableRowHelpers(rowHelpersConfig) - - // Create refresh trigger atom - const refreshTriggerAtom = atom(0) - - // Create the dataset store - const datasetStore = createInfiniteDatasetStore({ - key, - metaAtom, - createSkeletonRow: rowHelpers.createSkeletonRow, - mergeRow: rowHelpers.mergeRow, - isEnabled: isEnabled ?? ((meta) => Boolean(meta?.projectId)), - clientRowsAtom, - excludeRowIdsAtom, - fetchPage: async ({limit, offset, cursor, windowing, meta}) => { - if (!meta?.projectId) { - return { - rows: [], - totalCount: 0, - hasMore: false, - nextOffset: null, - nextCursor: null, - nextWindowing: null, - } - } - - return fetchData({meta, limit, offset, cursor, windowing}) - }, - }) - - return { - datasetStore, - rowHelpers, - refreshTriggerAtom, - } -} - -/** - * Helper to create a meta atom that combines projectId with filters. - * Provides a consistent pattern for table metadata atoms. - */ -export function createTableMetaAtom>(config: { - projectIdAtom: Atom - refreshTriggerAtom: Atom - filterAtoms: {[K in keyof TFilters]: Atom} -}): Atom { - const {projectIdAtom, refreshTriggerAtom, filterAtoms} = config - - return atom((get) => { - const projectId = get(projectIdAtom) - const _refreshTrigger = get(refreshTriggerAtom) - - const filters = {} as TFilters - for (const key of Object.keys(filterAtoms) as (keyof TFilters)[]) { - filters[key] = get(filterAtoms[key]) - } - - return { - projectId, - _refreshTrigger, - ...filters, - } - }) -} diff --git a/web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts b/web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts deleted file mode 100644 index 1a4ed21db7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/helpers/createTableRowHelpers.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type {WindowingState, InfiniteTableRowBase} from "../types" - -/** - * Configuration for creating table row helpers - */ -export interface TableRowHelpersConfig { - /** Prefix for skeleton row keys (e.g., "testset", "evaluation-run") */ - entityName: string - /** Default values for skeleton rows */ - skeletonDefaults: Omit - /** Extract the unique ID from an API row (used as the row key) */ - getRowId: (apiRow: TApiRow) => string - /** - * Optional custom merge logic. If not provided, uses simple spread. - * Use this when you need to transform API data or handle null values specially. - */ - customMerge?: (skeleton: TRow, apiRow: TApiRow) => TRow -} - -/** - * Parameters for creating a skeleton row - */ -export interface CreateSkeletonRowParams { - scopeId: string | null - offset: number - index: number - windowing: WindowingState | null - rowKey: string -} - -/** - * Parameters for merging a skeleton with API data - */ -export interface MergeRowParams { - skeleton: TRow - apiRow?: TApiRow -} - -/** - * Creates reusable skeleton and merge row functions for a table. - * Reduces boilerplate by providing a consistent pattern for all tables. - * - * @example - * ```ts - * const {createSkeletonRow, mergeRow} = createTableRowHelpers({ - * entityName: "testset", - * skeletonDefaults: { - * id: "", - * name: "", - * created_at: "", - * updated_at: "", - * }, - * getRowId: (row) => row.id, - * }) - * ``` - */ -export function createTableRowHelpers( - config: TableRowHelpersConfig, -) { - const {entityName, skeletonDefaults, getRowId, customMerge} = config - - /** - * Creates a skeleton row for loading states - */ - const createSkeletonRow = ({scopeId, offset, index, rowKey}: CreateSkeletonRowParams): TRow => { - const computedIndex = offset + index + 1 - const scopePrefix = scopeId ? `${scopeId}::` : "" - const key = `${scopePrefix}skeleton-${entityName}-${computedIndex}-${rowKey}` - - return { - ...skeletonDefaults, - key, - __isSkeleton: true, - } as TRow - } - - /** - * Merges a skeleton row with actual API data - */ - const mergeRow = ({skeleton, apiRow}: MergeRowParams): TRow => { - if (!apiRow) { - return skeleton - } - - if (customMerge) { - return customMerge(skeleton, apiRow) - } - - // Default merge: spread API row and add key + skeleton flag - return { - ...apiRow, - key: getRowId(apiRow), - __isSkeleton: false, - } as unknown as TRow - } - - return { - createSkeletonRow, - mergeRow, - } -} - -export type TableRowHelpers = ReturnType< - typeof createTableRowHelpers -> diff --git a/web/oss/src/components/InfiniteVirtualTable/helpers/index.ts b/web/oss/src/components/InfiniteVirtualTable/helpers/index.ts deleted file mode 100644 index 25e3ec77fa..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/helpers/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -export {createTableRowHelpers} from "./createTableRowHelpers" -export type { - TableRowHelpersConfig, - CreateSkeletonRowParams, - MergeRowParams, - TableRowHelpers, -} from "./createTableRowHelpers" - -export {createSimpleTableStore, createTableMetaAtom} from "./createSimpleTableStore" -export type { - DateRangeFilter, - BaseTableMeta, - SimpleTableStoreConfig, - SimpleTableStore, -} from "./createSimpleTableStore" diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts deleted file mode 100644 index f4c5c4be19..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnDomRefs.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {useLayoutEffect, useRef} from "react" - -import type {ColumnsType} from "antd/es/table" - -interface ColumnDomRefs { - cols: HTMLTableColElement[] - headers: HTMLTableCellElement[] -} - -/** - * Hook to track and manage column DOM element references for live resizing - */ -const useColumnDomRefs = ( - containerRef: React.RefObject, - columns: ColumnsType, -) => { - const columnDomRefs = useRef>(new Map()) - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - columnDomRefs.current = new Map() - return - } - - const headerCells = Array.from( - container.querySelectorAll( - ".ant-table-thead th[data-column-key]", - ), - ).filter((cell) => Number(cell.getAttribute("colspan") ?? "1") === 1) - - if (!headerCells.length) { - columnDomRefs.current = new Map() - return - } - - const keyToIndices = new Map() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - const index = cell.cellIndex - if (index < 0) return - if (!keyToIndices.has(key)) { - keyToIndices.set(key, []) - } - keyToIndices.get(key)!.push(index) - }) - - const registry = new Map() - headerCells.forEach((cell) => { - const key = cell.dataset.columnKey - if (!key) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.headers.push(cell) - }) - - const tables = container.querySelectorAll(".ant-table table") - tables.forEach((table) => { - const cols = table.querySelectorAll("colgroup col") - keyToIndices.forEach((indices, key) => { - indices.forEach((idx) => { - const col = cols[idx] - if (!col) return - if (!registry.has(key)) { - registry.set(key, {cols: [], headers: []}) - } - registry.get(key)!.cols.push(col) - }) - }) - }) - - columnDomRefs.current = registry - }, [columns, containerRef]) - - return columnDomRefs -} - -export default useColumnDomRefs diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts deleted file mode 100644 index 564c40ddb1..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibility.ts +++ /dev/null @@ -1,288 +0,0 @@ -import {useCallback, useMemo, useRef} from "react" -import type {ReactNode} from "react" - -import type {ColumnsType} from "antd/es/table" -import {useAtomValue} from "jotai" -import {LOW_PRIORITY, useSetAtomWithSchedule} from "jotai-scheduler" - -import {getColumnHiddenKeysAtom} from "../atoms/columnHiddenKeys" - -type Key = string - -interface Options { - storageKey?: string - defaultHiddenKeys?: Key[] -} - -type ColumnLike = ColumnsType[number] & { - key?: React.Key - children?: ColumnLike[] - columnVisibilityTitle?: ReactNode - columnVisibilityLabel?: string - columnVisibilityLocked?: boolean -} - -const isColumnLocked = (column: ColumnLike | null | undefined) => - Boolean(column?.columnVisibilityLocked) - -export interface ColumnTreeNode { - key: Key - label: string - titleNode?: ReactNode - children: ColumnTreeNode[] - checked: boolean - indeterminate: boolean -} - -const toKey = (key: React.Key | undefined): Key | null => - key === undefined || key === null ? null : String(key) - -const collectKeys = (columns: ColumnsType): Key[] => { - const result: Key[] = [] - const visit = (cols: ColumnLike[]) => { - cols.forEach((col) => { - const k = toKey(col.key) - if (k && !isColumnLocked(col)) result.push(k) - if (col.children && col.children.length) visit(col.children as any) - }) - } - visit(columns as any) - return Array.from(new Set(result)) -} - -const collectLeafKeys = (columns: ColumnsType): Key[] => { - const result: Key[] = [] - const visit = (cols: ColumnLike[]) => { - cols.forEach((col) => { - if (col.children && col.children.length) { - visit(col.children as any) - } else { - const k = toKey(col.key) - if (k && !isColumnLocked(col)) result.push(k) - } - }) - } - visit(columns as any) - return Array.from(new Set(result)) -} - -const filterColumnsRecursive = ( - columns: ColumnsType, - hidden: Set, -): ColumnsType => { - const map = (cols: ColumnLike[]): ColumnLike[] => - cols - .map((col) => { - const k = toKey(col.key) - if (k && hidden.has(k) && !isColumnLocked(col)) return null - if (col.children && col.children.length) { - const children = map(col.children as any) - if (!children.length) return null - return {...col, children} as any - } - return col as any - }) - .filter(Boolean) as ColumnLike[] - - return map(columns as any) as any -} - -export const useColumnVisibility = ( - columns: ColumnsType, - {storageKey, defaultHiddenKeys = []}: Options = {}, -) => { - const allKeys = useMemo(() => collectKeys(columns), [columns]) - const leafKeys = useMemo(() => collectLeafKeys(columns), [columns]) - - const defaultHiddenSignature = useMemo( - () => (defaultHiddenKeys.length ? defaultHiddenKeys.join("|") : "__none__"), - [defaultHiddenKeys], - ) - const defaultHiddenSnapshot = useMemo(() => [...defaultHiddenKeys], [defaultHiddenSignature]) - const hiddenKeysAtom = useMemo( - () => getColumnHiddenKeysAtom(storageKey, defaultHiddenSnapshot), - [defaultHiddenSnapshot, storageKey], - ) - const hiddenKeys = useAtomValue(hiddenKeysAtom) - const setHiddenKeys = useSetAtomWithSchedule(hiddenKeysAtom, { - priority: LOW_PRIORITY, - }) - - const hiddenSet = useMemo( - () => new Set(hiddenKeys.map((key) => String(key))) as Set, - [hiddenKeys], - ) - - const visibleColumns = useMemo( - () => filterColumnsRecursive(columns, hiddenSet), - [columns, hiddenSet], - ) - - const isHidden = useCallback((key: Key) => hiddenSet.has(key), [hiddenSet]) - - const showColumn = useCallback( - (key: Key) => { - setHiddenKeys((prev) => prev.filter((k) => k !== key)) - }, - [setHiddenKeys], - ) - - const hideColumn = useCallback( - (key: Key) => { - setHiddenKeys((prev) => (prev.includes(key) ? prev : [...prev, key])) - }, - [setHiddenKeys], - ) - - const toggleColumn = useCallback( - (key: Key) => (hiddenSet.has(key) ? showColumn(key) : hideColumn(key)), - [hideColumn, hiddenSet, showColumn], - ) - - const reset = useCallback( - () => setHiddenKeys(defaultHiddenKeys), - [defaultHiddenKeys, setHiddenKeys], - ) - - const collectDescendantKeys = useCallback( - (cols: ColumnsType, target: Key): Key[] => { - const keys: Key[] = [] - const visit = (items: ColumnLike[]) => { - items.forEach((col) => { - const k = toKey(col.key) - if (k === target) { - // include self and all descendants - const gather = (node: ColumnLike) => { - const nk = toKey(node.key) - if (nk && !isColumnLocked(node)) keys.push(nk) - if (node.children && node.children.length) { - node.children.forEach((child) => gather(child as any)) - } - } - gather(col) - } else if (col.children && col.children.length) { - visit(col.children as any) - } - }) - } - visit(cols as any) - return Array.from(new Set(keys)) - }, - [], - ) - - const toggleTree = useCallback( - (groupKey: Key) => { - const keys = collectDescendantKeys(columns, groupKey) - if (!keys.length) { - toggleColumn(groupKey) - return - } - const anyVisible = keys.some((k) => !hiddenSet.has(k)) - setHiddenKeys((prev) => { - const base = new Set(prev) - if (anyVisible) { - keys.forEach((k) => base.add(k)) - } else { - keys.forEach((k) => base.delete(k)) - } - return Array.from(base) - }) - }, - [collectDescendantKeys, columns, hiddenSet, setHiddenKeys, toggleColumn], - ) - - const getLabel = (col: ColumnLike): string => { - if (typeof col.columnVisibilityLabel === "string" && col.columnVisibilityLabel.length) { - return col.columnVisibilityLabel - } - const title = (col as any)?.title - const label = typeof title === "string" ? title : toKey(col.key) - return label ?? "" - } - - const buildTree = useCallback( - (cols: ColumnsType): ColumnTreeNode[] => { - const map = (items: ColumnLike[]): ColumnTreeNode[] => { - const nodes: ColumnTreeNode[] = [] - items.forEach((col) => { - const k = toKey(col.key) - const children = - col.children && col.children.length ? map(col.children as any) : [] - if (!k || isColumnLocked(col)) { - nodes.push(...children) - return - } - const subtreeKeys: Key[] = [ - k, - ...collectDescendantKeys([col] as any, k).filter((x) => x !== k), - ] - const hiddenCount = subtreeKeys.filter((x) => hiddenSet.has(x)).length - const allHidden = hiddenCount === subtreeKeys.length - const noneHidden = hiddenCount === 0 - nodes.push({ - key: k, - label: getLabel(col), - titleNode: col.columnVisibilityTitle, - children, - checked: noneHidden, - indeterminate: !noneHidden && !allHidden, - }) - }) - return nodes - } - return map(cols as any) - }, - [collectDescendantKeys, hiddenSet], - ) - - const columnTree = useMemo(() => buildTree(columns), [buildTree, columns]) - - const columnTreeStructureSignature = useMemo(() => { - const serialize = (nodes: ColumnTreeNode[]): any => - nodes.map((node) => ({ - key: node.key, - children: serialize(node.children), - })) - return JSON.stringify(serialize(columnTree)) - }, [columnTree]) - - const visibilitySignature = useMemo(() => { - const normalizedHidden = [...hiddenKeys].sort().join("|") - const normalizedLeaf = leafKeys.join("|") - const normalizedAll = allKeys.join("|") - return `${normalizedAll}__${normalizedLeaf}__${normalizedHidden}__${columnTreeStructureSignature}` - }, [allKeys, columnTreeStructureSignature, hiddenKeys, leafKeys]) - - const visibilitySignatureRef = useRef(null) - const versionRef = useRef(0) - - const version = useMemo(() => { - if (!visibilitySignature) { - return versionRef.current - } - if (visibilitySignatureRef.current !== visibilitySignature) { - visibilitySignatureRef.current = visibilitySignature - versionRef.current += 1 - } - return versionRef.current - }, [visibilitySignature]) - - return { - allKeys, - leafKeys, - hiddenKeys, - setHiddenKeys, - isHidden, - showColumn, - hideColumn, - toggleColumn, - toggleTree, - reset, - visibleColumns, - columnTree, - version, - } -} - -export default useColumnVisibility diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts deleted file mode 100644 index cb17f10147..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useColumnVisibilityControls.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {useCallback, useMemo} from "react" -import type {Key} from "react" - -import type {ColumnVisibilityState} from "../types" - -interface ColumnVisibilityHookResult { - visibleColumns: any[] - leafKeys: any[] - allKeys: any[] - hiddenKeys: any[] - isHidden: (key: any) => boolean - showColumn: (key: any) => void - hideColumn: (key: any) => void - toggleColumn: (key: any) => void - toggleTree: (key: any) => void - reset: () => void - columnTree: any[] - setHiddenKeys: (keys: any) => void - version: number -} - -/** - * Creates normalized column visibility controls that work with React.Key - */ -const useColumnVisibilityControls = ( - hookResult: ColumnVisibilityHookResult, -): ColumnVisibilityState => { - const { - visibleColumns, - leafKeys, - allKeys, - hiddenKeys, - isHidden, - showColumn, - hideColumn, - toggleColumn, - toggleTree, - reset, - columnTree, - setHiddenKeys, - version, - } = hookResult - - const normalizedIsHidden = useCallback((key: Key) => isHidden(String(key)), [isHidden]) - const normalizedShowColumn = useCallback((key: Key) => showColumn(String(key)), [showColumn]) - const normalizedHideColumn = useCallback((key: Key) => hideColumn(String(key)), [hideColumn]) - const normalizedToggleColumn = useCallback( - (key: Key) => toggleColumn(String(key)), - [toggleColumn], - ) - const normalizedToggleTree = useCallback((key: Key) => toggleTree(String(key)), [toggleTree]) - const normalizedSetHiddenKeys = useCallback( - (keys: Key[]) => setHiddenKeys(keys.map((key) => String(key))), - [setHiddenKeys], - ) - - const controls = useMemo>( - () => ({ - columnTree, - leafKeys, - allKeys, - hiddenKeys, - isHidden: normalizedIsHidden, - showColumn: normalizedShowColumn, - hideColumn: normalizedHideColumn, - toggleColumn: normalizedToggleColumn, - toggleTree: normalizedToggleTree, - reset, - setHiddenKeys: normalizedSetHiddenKeys, - visibleColumns, - version, - }), - [ - columnTree, - leafKeys, - allKeys, - hiddenKeys, - normalizedIsHidden, - normalizedShowColumn, - normalizedHideColumn, - normalizedToggleColumn, - normalizedToggleTree, - reset, - normalizedSetHiddenKeys, - visibleColumns, - version, - ], - ) - - return controls -} - -export default useColumnVisibilityControls diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts deleted file mode 100644 index 692a4780ef..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerResize.ts +++ /dev/null @@ -1,76 +0,0 @@ -import {useEffect, useLayoutEffect, useState} from "react" - -interface ContainerSize { - width: number - height: number -} - -// Measure before the browser paints on the client; fall back to useEffect on the -// server to avoid the SSR useLayoutEffect warning. -const useIsomorphicLayoutEffect = typeof document !== "undefined" ? useLayoutEffect : useEffect - -/** - * Hook to observe container dimensions using ResizeObserver with RAF throttling. - * - * The initial size is measured synchronously in a layout effect so the first - * painted frame already has the real container height. Without this, the size - * starts at 0 and only updates a frame later (post-paint), which makes the - * virtual table fall back to a ~360px viewport (see `useScrollConfig`) and - * visibly grow to full height on every mount/navigation. - */ -const useContainerResize = ( - containerRef: React.RefObject, -): ContainerSize => { - const [containerSize, setContainerSize] = useState({ - width: 0, - height: 0, - }) - - useIsomorphicLayoutEffect(() => { - const element = containerRef.current - if (!element) return - - const applySize = (nextWidth: number, nextHeight: number) => { - setContainerSize((prev) => { - if (prev.width === nextWidth && prev.height === nextHeight) { - return prev - } - return {width: nextWidth, height: nextHeight} - }) - } - - // Synchronous first measurement so the initial paint uses the real height - // rather than 0 (and therefore the 360px scroll fallback). - applySize(element.clientWidth, element.clientHeight) - - let frameId: number | null = null - const observer = new ResizeObserver((entries) => { - const entry = entries[0] - if (!entry) return - const contentBoxSize = Array.isArray(entry.contentBoxSize) - ? entry.contentBoxSize[0] - : entry.contentBoxSize - const nextWidth = - contentBoxSize?.inlineSize ?? entry.contentRect?.width ?? element.clientWidth - const nextHeight = - contentBoxSize?.blockSize ?? entry.contentRect?.height ?? element.clientHeight - - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - frameId = requestAnimationFrame(() => applySize(nextWidth, nextHeight)) - }) - - observer.observe(element) - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - observer.disconnect() - } - }, [containerRef]) - - return containerSize -} - -export default useContainerResize diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts deleted file mode 100644 index a2e59d8725..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useContainerSize.ts +++ /dev/null @@ -1,58 +0,0 @@ -import {useEffect, useRef, useState} from "react" - -interface ContainerSize { - width: number - height: number -} - -/** - * Hook to observe and track container dimensions using ResizeObserver - */ -const useContainerSize = () => { - const containerRef = useRef(null) - const [containerSize, setContainerSize] = useState({width: 0, height: 0}) - - useEffect(() => { - const element = containerRef.current - if (!element) return - - let frameId: number | null = null - const observer = new ResizeObserver((entries) => { - const entry = entries[0] - if (!entry) return - const contentBoxSize = Array.isArray(entry.contentBoxSize) - ? entry.contentBoxSize[0] - : entry.contentBoxSize - const nextWidth = - contentBoxSize?.inlineSize ?? entry.contentRect?.width ?? element.clientWidth - const nextHeight = - contentBoxSize?.blockSize ?? entry.contentRect?.height ?? element.clientHeight - - const update = () => { - setContainerSize((prev) => { - if (prev.width === nextWidth && prev.height === nextHeight) { - return prev - } - return {width: nextWidth, height: nextHeight} - }) - } - - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - frameId = requestAnimationFrame(update) - }) - - observer.observe(element) - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId) - } - observer.disconnect() - } - }, []) - - return {containerRef, containerSize} -} - -export default useContainerSize diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts deleted file mode 100644 index 0112ea7a89..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useEditableTable.ts +++ /dev/null @@ -1,454 +0,0 @@ -import {useCallback, useMemo, useState} from "react" - -import type {InfiniteTableRowBase} from "../types" - -export interface EditableTableColumn { - /** Column key/dataIndex */ - key: string - /** Display name */ - name: string -} - -export interface EditableTableConfig { - /** Initial columns derived from data or provided explicitly */ - initialColumns?: EditableTableColumn[] - /** System fields to exclude when deriving columns from row data */ - systemFields?: string[] - /** Callback when a cell value changes */ - onCellChange?: (rowId: string, columnKey: string, value: unknown) => void - /** Callback when columns change (add/rename/delete) */ - onColumnsChange?: (columns: EditableTableColumn[]) => void - /** Callback when rows are added */ - onRowsAdd?: (rows: Row[]) => void - /** Callback when rows are deleted */ - onRowsDelete?: (rowIds: string[]) => void - /** Generate a new row with default values */ - createNewRow?: () => Partial -} - -export interface EditableTableState { - /** Current columns */ - columns: EditableTableColumn[] - /** Local edits map: rowId -> { columnKey: value } */ - localEdits: Map> - /** New rows not yet persisted */ - newRows: Row[] - /** Row IDs marked for deletion */ - deletedRowIds: Set - /** Whether there are unsaved changes */ - hasUnsavedChanges: boolean - /** Derive columns from first row data */ - deriveColumnsFromRow: (row: Row) => void -} - -export interface EditableTableActions { - /** Edit a cell value. Pass originalValue to auto-clear edit when value matches original. */ - editCell: (rowId: string, columnKey: string, value: unknown, originalValue?: unknown) => void - /** Add a new row and return it */ - addRow: () => Row - /** Delete rows by IDs */ - deleteRows: (rowIds: string[]) => void - /** Add a new column */ - addColumn: (name: string) => boolean - /** Rename a column */ - renameColumn: (oldName: string, newName: string) => boolean - /** Delete a column */ - deleteColumn: (columnKey: string) => void - /** Set columns explicitly */ - setColumns: (columns: EditableTableColumn[]) => void - /** Get the display value for a cell (with local edits applied) */ - getCellValue: (row: Row, columnKey: string) => unknown - /** Get all rows with edits applied and new rows included */ - getDisplayRows: (serverRows: Row[]) => Row[] - /** Get final row data for saving (only column values) */ - getFinalRowData: (serverRows: Row[]) => Record[] - /** Clear all local state (after save) */ - clearLocalState: () => void - /** Reset all state including columns (for revision switching) */ - resetAllState: () => void - /** Mark changes as saved */ - markAsSaved: () => void -} - -const DEFAULT_SYSTEM_FIELDS = ["id", "key", "created_at", "updated_at", "__isSkeleton"] - -export function useEditableTable( - config: EditableTableConfig = {}, -): [EditableTableState, EditableTableActions] { - const { - initialColumns = [], - systemFields = DEFAULT_SYSTEM_FIELDS, - onCellChange, - onColumnsChange, - onRowsAdd, - onRowsDelete, - createNewRow, - } = config - - const [columns, setColumnsState] = useState(initialColumns) - const [originalColumns, setOriginalColumns] = useState(initialColumns) - const [localEdits, setLocalEdits] = useState>>(new Map()) - const [newRows, setNewRows] = useState([]) - const [deletedRowIds, setDeletedRowIds] = useState>(new Set()) - - const systemFieldsSet = useMemo(() => new Set(systemFields), [systemFields]) - - // Edit a cell value - const editCell = useCallback( - (rowId: string, columnKey: string, value: unknown, originalValue?: unknown) => { - const isNewRow = newRows.some((r) => String(r.key) === rowId || r.id === rowId) - - if (isNewRow) { - setNewRows((prev) => - prev.map((r) => { - if (String(r.key) === rowId || r.id === rowId) { - return {...r, [columnKey]: value} - } - return r - }), - ) - } else { - setLocalEdits((prev) => { - const next = new Map(prev) - const existing = next.get(rowId) || {} - - // If value matches original, remove this edit - if (originalValue !== undefined && value === originalValue) { - const {[columnKey]: _removed, ...rest} = existing - if (Object.keys(rest).length === 0) { - next.delete(rowId) - } else { - next.set(rowId, rest) - } - } else { - next.set(rowId, {...existing, [columnKey]: value}) - } - - return next - }) - } - - onCellChange?.(rowId, columnKey, value) - }, - [newRows, onCellChange], - ) - - // Add a new row - const addRow = useCallback((): Row => { - const timestamp = Date.now() - const baseRow = createNewRow?.() || {} - const newRow = { - key: `new-${timestamp}`, - id: `new-${timestamp}`, - __isSkeleton: false, - ...baseRow, - } as unknown as Row - - // Initialize all columns with empty strings - columns.forEach((col) => { - if (!(col.key in newRow)) { - ;(newRow as Record)[col.key] = "" - } - }) - - setNewRows((prev) => [...prev, newRow]) - onRowsAdd?.([newRow]) - return newRow - }, [columns, createNewRow, onRowsAdd]) - - // Delete rows - const deleteRows = useCallback( - (rowIds: string[]) => { - const newRowKeys = new Set(newRows.map((r) => String(r.key))) - const existingToDelete = rowIds.filter((id) => !newRowKeys.has(id)) - const newToDelete = rowIds.filter((id) => newRowKeys.has(id)) - - if (newToDelete.length > 0) { - setNewRows((prev) => prev.filter((r) => !newToDelete.includes(String(r.key)))) - } - - if (existingToDelete.length > 0) { - setDeletedRowIds((prev) => { - const next = new Set(prev) - existingToDelete.forEach((id) => next.add(id)) - return next - }) - } - - onRowsDelete?.(rowIds) - }, - [newRows, onRowsDelete], - ) - - // Add a new column - const addColumn = useCallback( - (name: string): boolean => { - const trimmedName = name.trim() - if (!trimmedName) return false - if (columns.some((c) => c.key === trimmedName || c.name === trimmedName)) return false - - const newColumn: EditableTableColumn = {key: trimmedName, name: trimmedName} - const newColumns = [...columns, newColumn] - setColumnsState(newColumns) - onColumnsChange?.(newColumns) - return true - }, - [columns, onColumnsChange], - ) - - // Rename a column - const renameColumn = useCallback( - (oldName: string, newName: string): boolean => { - const trimmedNewName = newName.trim() - if (!trimmedNewName) return false - if (oldName === trimmedNewName) return true - if (columns.some((c) => c.key === trimmedNewName && c.key !== oldName)) return false - - const newColumns = columns.map((c) => - c.key === oldName ? {key: trimmedNewName, name: trimmedNewName} : c, - ) - setColumnsState(newColumns) - - // Update local edits to use new key - setLocalEdits((prev) => { - const next = new Map>() - prev.forEach((edits, rowId) => { - const newEdits: Record = {} - Object.entries(edits).forEach(([key, value]) => { - newEdits[key === oldName ? trimmedNewName : key] = value - }) - next.set(rowId, newEdits) - }) - return next - }) - - // Update new rows - setNewRows((prev) => - prev.map((r) => { - if (oldName in r) { - const newRow = {...r} - ;(newRow as Record)[trimmedNewName] = r[oldName] - delete (newRow as Record)[oldName] - return newRow - } - return r - }), - ) - - onColumnsChange?.(newColumns) - return true - }, - [columns, onColumnsChange], - ) - - // Delete a column - const deleteColumn = useCallback( - (columnKey: string) => { - const newColumns = columns.filter((c) => c.key !== columnKey) - setColumnsState(newColumns) - - // Remove from local edits - setLocalEdits((prev) => { - const next = new Map>() - prev.forEach((edits, rowId) => { - const newEdits: Record = {} - Object.entries(edits).forEach(([key, value]) => { - if (key !== columnKey) { - newEdits[key] = value - } - }) - if (Object.keys(newEdits).length > 0) { - next.set(rowId, newEdits) - } - }) - return next - }) - - // Remove from new rows - setNewRows((prev) => - prev.map((r) => { - const newRow = {...r} - delete (newRow as Record)[columnKey] - return newRow - }), - ) - - onColumnsChange?.(newColumns) - }, - [columns, onColumnsChange], - ) - - // Set columns explicitly - const setColumns = useCallback( - (newColumns: EditableTableColumn[]) => { - setColumnsState(newColumns) - onColumnsChange?.(newColumns) - }, - [onColumnsChange], - ) - - // Get cell value with local edits applied - const getCellValue = useCallback( - (row: Row, columnKey: string): unknown => { - // Always use row.key as the unique identifier - const rowKey = String(row.key) - const edits = localEdits.get(rowKey) - if (edits && columnKey in edits) { - return edits[columnKey] - } - return row[columnKey] - }, - [localEdits], - ) - - // Get display rows with edits applied - // New rows are prepended at the top to avoid UX issues with infinite scrolling - const getDisplayRows = useCallback( - (serverRows: Row[]): Row[] => { - const filteredRows = serverRows - .filter((row) => { - // Always use row.key as the unique identifier - const rowKey = String(row.key) - return !deletedRowIds.has(rowKey) - }) - .map((row) => { - const rowKey = String(row.key) - const edits = localEdits.get(rowKey) - if (edits) { - return {...row, ...edits} - } - return row - }) - - // Prepend new rows at the top (reversed so newest is first) - return [...newRows.slice().reverse(), ...filteredRows] - }, - [deletedRowIds, localEdits, newRows], - ) - - // Get final row data for saving - const getFinalRowData = useCallback( - (serverRows: Row[]): Record[] => { - const displayRows = getDisplayRows(serverRows) - return displayRows.map((row) => { - const rowData: Record = {} - columns.forEach((col) => { - rowData[col.key] = row[col.key] ?? "" - }) - return rowData - }) - }, - [columns, getDisplayRows], - ) - - // Clear local state (edits, new rows, deleted rows) - const clearLocalState = useCallback(() => { - setLocalEdits(new Map()) - setNewRows([]) - setDeletedRowIds(new Set()) - // Also sync original columns with current columns after save - setOriginalColumns(columns) - }, [columns]) - - // Reset all state including columns (for revision switching) - const resetAllState = useCallback(() => { - setLocalEdits(new Map()) - setNewRows([]) - setDeletedRowIds(new Set()) - setColumnsState([]) - setOriginalColumns([]) - }, []) - - // Mark as saved (syncs original columns with current) - const markAsSaved = useCallback(() => { - setOriginalColumns(columns) - }, [columns]) - - // Derive columns from first row if not set - const deriveColumnsFromRow = useCallback( - (row: Row) => { - if (columns.length > 0) return - - const dynamicCols = Object.keys(row) - .filter((key) => !systemFieldsSet.has(key)) - .map((key) => ({key, name: key})) - - if (dynamicCols.length > 0) { - setColumnsState(dynamicCols) - setOriginalColumns(dynamicCols) // Track original columns from server - onColumnsChange?.(dynamicCols) - } - }, - [columns.length, systemFieldsSet, onColumnsChange], - ) - - // Compute hasUnsavedChanges based on actual differences - const hasUnsavedChanges = useMemo(() => { - // Check for new rows - if (newRows.length > 0) return true - - // Check for deleted rows - if (deletedRowIds.size > 0) return true - - // Check for local edits (cell changes) - if (localEdits.size > 0) return true - - // Check for column changes (added, removed, or renamed) - if (columns.length !== originalColumns.length) return true - - // Check if any column was renamed or reordered - const columnsChanged = columns.some((col, index) => { - const origCol = originalColumns[index] - return !origCol || col.key !== origCol.key || col.name !== origCol.name - }) - if (columnsChanged) return true - - return false - }, [newRows.length, deletedRowIds.size, localEdits.size, columns, originalColumns]) - - const state: EditableTableState = { - columns, - localEdits, - newRows, - deletedRowIds, - hasUnsavedChanges, - deriveColumnsFromRow, - } - - const actions: EditableTableActions = useMemo( - () => ({ - editCell, - addRow, - deleteRows, - addColumn, - renameColumn, - deleteColumn, - setColumns, - getCellValue, - getDisplayRows, - getFinalRowData, - clearLocalState, - resetAllState, - markAsSaved, - }), - [ - editCell, - addRow, - deleteRows, - addColumn, - renameColumn, - deleteColumn, - setColumns, - getCellValue, - getDisplayRows, - getFinalRowData, - clearLocalState, - resetAllState, - markAsSaved, - ], - ) - - return [state, actions] -} - -export default useEditableTable diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx deleted file mode 100644 index 6a4e058710..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useExpandableRows.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import type {Key, ReactNode} from "react" -import {useCallback, useMemo, useRef, useState} from "react" - -import {MinusCircleOutlined, PlusCircleOutlined, LoadingOutlined} from "@ant-design/icons" -import type {TableProps} from "antd/es/table" - -import type {ExpandableRowConfig} from "../types" - -interface ExpandedRowState { - loading: boolean - error: Error | null - children: ChildType[] | null -} - -interface UseExpandableRowsConfig { - config: ExpandableRowConfig | undefined - rowKey: TableProps["rowKey"] - // dataSource is available for future use (e.g., clearing cache on data change) - _dataSource?: RecordType[] -} - -interface UseExpandableRowsReturn { - expandedRowKeys: Key[] - expandedRowRender: ((record: RecordType) => ReactNode) | undefined - onExpand: (expanded: boolean, record: RecordType) => void - expandIcon: - | ((props: { - expanded: boolean - onExpand: (record: RecordType, e: React.MouseEvent) => void - record: RecordType - }) => ReactNode) - | undefined - rowExpandable: ((record: RecordType) => boolean) | undefined - expandColumnWidth: number | undefined - expandFixed: "left" | "right" | undefined - /** - * Render function for the expand icon that can be used within a cell. - * Use this when showExpandIconInCell is true. - */ - renderExpandIcon: (record: RecordType) => ReactNode - /** - * Check if a specific row is expanded - */ - isExpanded: (record: RecordType) => boolean -} - -/** - * Hook to manage expandable row state and behavior for InfiniteVirtualTable. - * Handles async data fetching, caching, and rendering of expanded content. - */ -export function useExpandableRows({ - config, - rowKey, - dataSource, -}: UseExpandableRowsConfig): UseExpandableRowsReturn { - const [expandedRowKeys, setExpandedRowKeys] = useState([]) - const [expandedStates, setExpandedStates] = useState>>( - new Map(), - ) - const childrenCacheRef = useRef>(new Map()) - - // Helper to get row key from record - const getRowKey = useCallback( - (record: RecordType): Key => { - if (typeof rowKey === "function") { - return rowKey(record) - } - return (record as Record)[rowKey as string] as Key - }, - [rowKey], - ) - - // Handle row expand/collapse - const onExpand = useCallback( - async (expanded: boolean, record: RecordType) => { - if (!config) return - - const key = getRowKey(record) - const cacheChildren = config.cacheChildren !== false - - if (expanded) { - // Accordion mode: collapse other rows - if (config.accordion) { - setExpandedRowKeys([key]) - } else { - setExpandedRowKeys((prev) => [...prev, key]) - } - - // Check cache first - if (cacheChildren && childrenCacheRef.current.has(key)) { - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, { - loading: false, - error: null, - children: childrenCacheRef.current.get(key) ?? null, - }) - return next - }) - return - } - - // Set loading state - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, {loading: true, error: null, children: null}) - return next - }) - - // Fetch children - try { - const children = await config.fetchChildren(record) - if (cacheChildren) { - childrenCacheRef.current.set(key, children) - } - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, {loading: false, error: null, children}) - return next - }) - } catch (error) { - setExpandedStates((prev) => { - const next = new Map(prev) - next.set(key, { - loading: false, - error: error instanceof Error ? error : new Error(String(error)), - children: null, - }) - return next - }) - } - } else { - // Collapse - setExpandedRowKeys((prev) => prev.filter((k) => k !== key)) - } - }, - [config, getRowKey], - ) - - // Render expanded row content - const expandedRowRender = useMemo(() => { - if (!config) return undefined - - return (record: RecordType) => { - const key = getRowKey(record) - const state = expandedStates.get(key) - const loading = state?.loading ?? false - const error = state?.error ?? null - const children = state?.children ?? [] - - return config.renderExpanded(record, children, loading, error) - } - }, [config, expandedStates, getRowKey]) - - // Custom expand icon - const expandIcon = useMemo(() => { - if (!config) return undefined - - return ({ - expanded, - onExpand: triggerExpand, - record, - }: { - expanded: boolean - onExpand: (record: RecordType, e: React.MouseEvent) => void - record: RecordType - }) => { - const key = getRowKey(record) - const state = expandedStates.get(key) - const loading = state?.loading ?? false - - // Check if row is expandable - if (config.isExpandable && !config.isExpandable(record)) { - return - } - - // Custom icon renderer - if (config.expandIcon) { - return config.expandIcon({ - expanded, - onExpand: () => triggerExpand(record, {} as React.MouseEvent), - record, - loading, - }) - } - - // Default icon - circle style matching app registry - return ( - { - e.stopPropagation() - triggerExpand(record, e) - }} - > - {loading ? ( - - ) : expanded ? ( - - ) : ( - - )} - - ) - } - }, [config, expandedStates, getRowKey]) - - // Row expandable check - const rowExpandable = useMemo(() => { - if (!config) return undefined - if (!config.isExpandable) return undefined - return config.isExpandable - }, [config]) - - // Check if a record is expanded - const isExpanded = useCallback( - (record: RecordType): boolean => { - const key = getRowKey(record) - return expandedRowKeys.includes(key) - }, - [expandedRowKeys, getRowKey], - ) - - // Render expand icon for use within a cell (when showExpandIconInCell is true) - const renderExpandIcon = useCallback( - (record: RecordType): ReactNode => { - if (!config) return null - - // Check if row is expandable - if (config.isExpandable && !config.isExpandable(record)) { - return - } - - const key = getRowKey(record) - const expanded = expandedRowKeys.includes(key) - const state = expandedStates.get(key) - const loading = state?.loading ?? false - - // Custom icon renderer - if (config.expandIcon) { - return config.expandIcon({ - expanded, - onExpand: () => onExpand(!expanded, record), - record, - loading, - }) - } - - // Default circle icon - return ( - { - e.stopPropagation() - onExpand(!expanded, record) - }} - > - {loading ? ( - - ) : expanded ? ( - - ) : ( - - )} - - ) - }, - [config, expandedRowKeys, expandedStates, getRowKey, onExpand], - ) - - return { - expandedRowKeys, - expandedRowRender, - onExpand, - expandIcon, - rowExpandable, - expandColumnWidth: config?.showExpandIconInCell ? 0 : (config?.columnWidth ?? 48), - expandFixed: config?.fixed, - renderExpandIcon, - isExpanded, - } -} - -export default useExpandableRows diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts deleted file mode 100644 index 6cadf8f4d1..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useHeaderViewportVisibility.ts +++ /dev/null @@ -1,435 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, type RefObject} from "react" - -import type {ColumnViewportVisibilityEvent} from "../types" - -type ViewportVisibilityCallback = ( - payload: ColumnViewportVisibilityEvent | ColumnViewportVisibilityEvent[], -) => void - -// const intersectionThresholds = [0, 0.01, 0.02, 0.1] -const intersectionThresholds = [0, 0, 0, 0] - -const useHeaderViewportVisibility = ({ - scopeId, - containerRef, - onVisibilityChange, - onColumnUnregister, - enabled = true, - viewportMargin, - exitDebounceMs = 150, - excludeKeys = [], - suspendUpdates = false, - descendantColumnMap, -}: { - scopeId: string | null - containerRef: RefObject - onVisibilityChange: ViewportVisibilityCallback | undefined - onColumnUnregister?: - | ((payload: {scopeId: string | null; columnKey: string}) => void) - | undefined - enabled?: boolean - viewportMargin?: string - exitDebounceMs?: number - excludeKeys?: string[] - suspendUpdates?: boolean - descendantColumnMap?: Map -}) => { - const excludedKeySet = useMemo(() => new Set(excludeKeys ?? []), [excludeKeys]) - const observerRef = useRef(null) - const keyToElementRef = useRef(new Map()) - const elementToKeyRef = useRef(new Map()) - const fixedKeysRef = useRef(new Set()) - const visibilityStateRef = useRef(new Map()) - const queuedUpdatesRef = useRef | null>(null) - const rafRef = useRef(null) - const hideTimeoutsRef = useRef(new Map()) - const pendingUnregisterTimeoutsRef = useRef(new Map()) - const suspendUpdatesRef = useRef(suspendUpdates) - - useEffect(() => { - suspendUpdatesRef.current = suspendUpdates - }, [suspendUpdates]) - - const clearHideTimeout = useCallback((columnKey: string) => { - const timeoutId = hideTimeoutsRef.current.get(columnKey) - if (timeoutId !== undefined && typeof window !== "undefined") { - window.clearTimeout(timeoutId) - } - hideTimeoutsRef.current.delete(columnKey) - }, []) - - const descendantMapRef = useRef>(descendantColumnMap ?? new Map()) - - useEffect(() => { - descendantMapRef.current = descendantColumnMap ?? new Map() - }, [descendantColumnMap]) - - const emitVisibilityChanges = useCallback( - (changes: {columnKey: string; visible: boolean}[]) => { - if (!scopeId || !changes.length) return - const deduped = new Map() - - const queueChange = (columnKey: string, visible: boolean) => { - const previous = visibilityStateRef.current.get(columnKey) - if (previous === visible) { - return - } - deduped.set(columnKey, visible) - } - - const propagate = (columnKey: string, visible: boolean) => { - queueChange(columnKey, visible) - const descendants = descendantMapRef.current.get(columnKey) ?? [] - descendants.forEach((childKey) => { - if (!childKey) return - propagate(childKey, visible) - }) - } - - changes.forEach(({columnKey, visible}) => { - propagate(columnKey, visible) - }) - const expandedChanges = Array.from(deduped.entries()).map(([columnKey, visible]) => ({ - columnKey, - visible, - })) - expandedChanges.forEach(({columnKey, visible}) => { - visibilityStateRef.current.set(columnKey, visible) - }) - const payload = expandedChanges.map( - ({columnKey, visible}): ColumnViewportVisibilityEvent => ({ - scopeId, - columnKey, - visible, - }), - ) - if (!payload.length) { - return - } - if (payload.length === 1) { - onVisibilityChange?.(payload[0]) - return - } - onVisibilityChange?.(payload) - }, - [onVisibilityChange, scopeId], - ) - - const flushQueuedUpdates = useCallback(() => { - rafRef.current = null - const updates = queuedUpdatesRef.current - queuedUpdatesRef.current = null - if (!updates || updates.size === 0) return - const changes = Array.from(updates.entries()).map(([columnKey, visible]) => ({ - columnKey, - visible, - })) - emitVisibilityChanges(changes) - }, [emitVisibilityChanges]) - - const enqueueVisibilityChange = useCallback( - (columnKey: string, visible: boolean) => { - const previous = visibilityStateRef.current.get(columnKey) - if (previous === visible) { - return - } - let queue = queuedUpdatesRef.current - if (!queue) { - queue = new Map() - queuedUpdatesRef.current = queue - } - queue.set(columnKey, visible) - if (rafRef.current === null && typeof window !== "undefined") { - rafRef.current = window.requestAnimationFrame(flushQueuedUpdates) - } - }, - [flushQueuedUpdates], - ) - - const queueVisibilityUpdate = useCallback( - (columnKey: string, visible: boolean) => { - if (visible) { - clearHideTimeout(columnKey) - enqueueVisibilityChange(columnKey, true) - return - } - const debounce = exitDebounceMs ?? 0 - if (debounce > 0 && typeof window !== "undefined") { - if (hideTimeoutsRef.current.has(columnKey)) { - return - } - const timeoutId = window.setTimeout(() => { - hideTimeoutsRef.current.delete(columnKey) - enqueueVisibilityChange(columnKey, false) - }, debounce) - hideTimeoutsRef.current.set(columnKey, timeoutId) - return - } - enqueueVisibilityChange(columnKey, false) - }, - [clearHideTimeout, enqueueVisibilityChange, exitDebounceMs], - ) - - // Track last known horizontal bounds to filter out vertical-only scroll events - const lastHorizontalBoundsRef = useRef(new Map()) - - const handleEntries = useCallback( - (entries: IntersectionObserverEntry[]) => { - // Skip processing if updates are suspended (e.g., during resize or vertical scroll) - if (suspendUpdatesRef.current) return - if (!onVisibilityChange || !scopeId) return - - // Batch process entries to reduce state updates during rapid scrolling - const updates: {columnKey: string; isVisible: boolean}[] = [] - - entries.forEach((entry) => { - const columnKey = elementToKeyRef.current.get(entry.target as HTMLElement) - if (!columnKey) return - - const boundingRect = entry.boundingClientRect - const intersectionRect = entry.intersectionRect - - // Check if horizontal position actually changed (ignore vertical-only scroll) - const lastBounds = lastHorizontalBoundsRef.current.get(columnKey) - const currentLeft = Math.round(boundingRect.left) - const currentRight = Math.round(boundingRect.right) - - if (lastBounds) { - const horizontalDelta = - Math.abs(currentLeft - lastBounds.left) + - Math.abs(currentRight - lastBounds.right) - // If horizontal position hasn't changed significantly, skip this update - // This filters out intersection events triggered by vertical scrolling - if (horizontalDelta < 2) { - return - } - } - - // Update last known horizontal bounds - lastHorizontalBoundsRef.current.set(columnKey, { - left: currentLeft, - right: currentRight, - }) - - const intersectionWidth = intersectionRect?.width ?? 0 - const intersectionHeight = intersectionRect?.height ?? 0 - const isVisible = - entry.isIntersecting && - intersectionWidth > 0 && - intersectionHeight > 0 && - boundingRect.width > 0 - - updates.push({columnKey, isVisible}) - }) - - // Process all updates together to minimize re-renders - updates.forEach(({columnKey, isVisible}) => { - queueVisibilityUpdate(columnKey, isVisible) - }) - }, - [onVisibilityChange, queueVisibilityUpdate, scopeId], - ) - - const lastRootRef = useRef(null) - const lastMarginRef = useRef(null) - - const ensureObserver = useCallback( - (enabled: boolean) => { - if (!enabled || !onVisibilityChange || !scopeId) { - return null - } - const currentRoot = containerRef.current - // const nextMargin = viewportMargin ?? "200px 200px 200px 200px" - const nextMargin = viewportMargin ?? "0px 0px 0px 0px" - - const createObserver = () => { - if (typeof window === "undefined") { - return null - } - // console.log("createObserver", {currentRoot, nextMargin, intersectionThresholds}) - const observer = new IntersectionObserver(handleEntries, { - root: currentRoot, - rootMargin: nextMargin, - threshold: intersectionThresholds, - }) - observerRef.current = observer - lastRootRef.current = currentRoot ?? null - lastMarginRef.current = nextMargin - keyToElementRef.current.forEach((element) => observer.observe(element)) - return observer - } - - if (observerRef.current) { - const marginChanged = lastMarginRef.current !== nextMargin - const rootChanged = lastRootRef.current !== currentRoot - if (!marginChanged && !rootChanged) { - return observerRef.current - } - observerRef.current.disconnect() - observerRef.current = null - } - - return createObserver() - }, - [containerRef, handleEntries, onVisibilityChange, scopeId, viewportMargin], - ) - - useEffect(() => { - if (!enabled || !onVisibilityChange || !scopeId) { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } - keyToElementRef.current.clear() - elementToKeyRef.current.clear() - visibilityStateRef.current.clear() - queuedUpdatesRef.current = null - if (typeof window !== "undefined") { - hideTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)) - } - hideTimeoutsRef.current.clear() - if (rafRef.current !== null && typeof window !== "undefined") { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - return - } - ensureObserver(enabled) - return () => { - if (observerRef.current) { - observerRef.current.disconnect() - observerRef.current = null - } - keyToElementRef.current.clear() - elementToKeyRef.current.clear() - visibilityStateRef.current.clear() - queuedUpdatesRef.current = null - if (typeof window !== "undefined") { - hideTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)) - } - hideTimeoutsRef.current.clear() - if (typeof window !== "undefined") { - pendingUnregisterTimeoutsRef.current.forEach((timeoutId) => - window.clearTimeout(timeoutId), - ) - } - pendingUnregisterTimeoutsRef.current.clear() - if (rafRef.current !== null && typeof window !== "undefined") { - window.cancelAnimationFrame(rafRef.current) - rafRef.current = null - } - } - }, [enabled, ensureObserver, onVisibilityChange, scopeId]) - - const isFixedHeaderNode = useCallback((node: HTMLElement | null) => { - if (!node) return false - const thNode = node.closest("th") - if (!thNode) return false - return ( - thNode.classList.contains("ant-table-cell-fix-left") || - thNode.classList.contains("ant-table-cell-fix-right") - ) - }, []) - - const registerHeader = useCallback( - (columnKey: string) => { - if (!enabled || !scopeId || !columnKey) { - return () => undefined - } - return (node: HTMLElement | null) => { - if (!enabled || !scopeId) return - if (node) { - const pendingTimeout = pendingUnregisterTimeoutsRef.current.get(columnKey) - if (pendingTimeout !== undefined && typeof window !== "undefined") { - window.clearTimeout(pendingTimeout) - pendingUnregisterTimeoutsRef.current.delete(columnKey) - } - if (excludedKeySet.has(columnKey) || isFixedHeaderNode(node)) { - fixedKeysRef.current.add(columnKey) - keyToElementRef.current.delete(columnKey) - // emitVisibilityChanges([{columnKey, visible: true}]) - return - } - const existingNode = keyToElementRef.current.get(columnKey) - if (existingNode === node) { - return - } - if (existingNode && observerRef.current) { - elementToKeyRef.current.delete(existingNode) - observerRef.current.unobserve(existingNode) - } - keyToElementRef.current.set(columnKey, node) - elementToKeyRef.current.set(node, columnKey) - const observer = ensureObserver(enabled) - // console.log("scopesWithChanges registerHeader", { - // columnKey, - // timestamp: Date.now(), - // }) - observer?.observe(node) - if (typeof window !== "undefined") { - // console.log("computeImmediateVisibility", {columnKey, node}) - // const visible = computeImmediateVisibility( - // node, - // containerRef.current, - // viewportMargin, - // ) - // emitVisibilityChanges([{columnKey, visible}]) - } - return - } - const wasFixed = fixedKeysRef.current.delete(columnKey) - if (wasFixed) { - // Fixed columns don't need cleanup - return - } - const previousNode = keyToElementRef.current.get(columnKey) - if (previousNode && observerRef.current) { - observerRef.current.unobserve(previousNode) - elementToKeyRef.current.delete(previousNode) - } - keyToElementRef.current.delete(columnKey) - - // Clear visibility state to prevent stale values on re-mount - const scheduleCleanup = () => { - visibilityStateRef.current.delete(columnKey) - // Delete from atom instead of setting to false to prevent stale state - // When column is re-registered, it will default to visible (true) - if (onColumnUnregister && scopeId) { - onColumnUnregister({scopeId, columnKey}) - } - } - - if (typeof window !== "undefined") { - if (!pendingUnregisterTimeoutsRef.current.has(columnKey)) { - const timeoutId = window.setTimeout(() => { - pendingUnregisterTimeoutsRef.current.delete(columnKey) - scheduleCleanup() - }, exitDebounceMs ?? 150) - pendingUnregisterTimeoutsRef.current.set(columnKey, timeoutId) - } - } else { - scheduleCleanup() - } - } - }, - [ - emitVisibilityChanges, - enabled, - ensureObserver, - excludedKeySet, - exitDebounceMs, - isFixedHeaderNode, - onVisibilityChange, - onColumnUnregister, - scopeId, - ], - ) - - if (!enabled || !scopeId) { - return undefined - } - - return registerHeader -} - -export default useHeaderViewportVisibility diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts deleted file mode 100644 index 203810b6fb..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteScroll.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {useCallback, useEffect, useRef} from "react" - -interface UseInfiniteScrollOptions { - loadMore: () => void - scrollThreshold?: number -} - -/** - * Hook to handle infinite scroll loading with RAF-based throttling - */ -const useInfiniteScroll = ({loadMore, scrollThreshold = 300}: UseInfiniteScrollOptions) => { - const scrollRafRef = useRef(null) - const lastScrollTargetRef = useRef(null) - - const handleScroll = useCallback( - (event: React.UIEvent) => { - // Store the scroll target for RAF callback - lastScrollTargetRef.current = event.currentTarget - - // Skip if we already have a pending RAF - if (scrollRafRef.current !== null) { - return - } - - // Defer layout reads to next animation frame to avoid forced reflow during scroll - scrollRafRef.current = requestAnimationFrame(() => { - scrollRafRef.current = null - const target = lastScrollTargetRef.current - if (!target) return - - const distanceToBottom = - target.scrollHeight - target.scrollTop - target.clientHeight - - if (distanceToBottom < scrollThreshold) { - loadMore() - } - }) - }, - [loadMore, scrollThreshold], - ) - - // Cleanup RAF on unmount - useEffect(() => { - return () => { - if (scrollRafRef.current !== null) { - cancelAnimationFrame(scrollRafRef.current) - } - } - }, []) - - return handleScroll -} - -export default useInfiniteScroll diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts deleted file mode 100644 index 27c83ea448..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useInfiniteTablePagination.ts +++ /dev/null @@ -1,144 +0,0 @@ -import {useCallback, useEffect, useMemo} from "react" - -import {useSetAtom} from "jotai" -import {LOW_PRIORITY, useAtomValueWithSchedule, useSetAtomWithSchedule} from "jotai-scheduler" - -import type {InfiniteTableStore} from "../createInfiniteTableStore" -import type {InfiniteTableRowBase, WindowingState} from "../types" - -interface UseInfiniteTablePaginationArgs { - store: InfiniteTableStore - scopeId: string | null - pageSize: number - resetOnScopeChange?: boolean -} - -interface PaginationResult { - rows: TableRow[] - rowsAtom: ReturnType["atoms"]["combinedRowsAtomFamily"]> - loadedRowCount: number - totalRows: number - loadNextPage: () => void - resetPages: () => void - paginationInfo: { - hasMore: boolean - nextCursor: string | null - nextOffset: number | null - isFetching: boolean - totalCount: number | null - nextWindowing: WindowingState | null - } -} - -const useInfiniteTablePagination = ({ - store, - scopeId, - pageSize, - resetOnScopeChange = true, -}: UseInfiniteTablePaginationArgs): PaginationResult => { - const debugEnabled = process.env.NEXT_PUBLIC_IVT_DEBUG === "true" - const pagesAtom = useMemo( - () => store.atoms.pagesAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - const combinedRowsAtom = useMemo( - () => store.atoms.combinedRowsAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - const paginationInfoAtom = useMemo( - () => store.atoms.paginationInfoAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - const scheduleAtom = useMemo( - () => store.atoms.scheduleNextPageAtomFamily({scopeId, pageSize}), - [store, scopeId, pageSize], - ) - - const setPagesState = useSetAtom(pagesAtom) - const scheduleNextPage = useSetAtomWithSchedule(scheduleAtom, { - priority: LOW_PRIORITY, - }) - const rows = useAtomValueWithSchedule(combinedRowsAtom, { - priority: LOW_PRIORITY, - }) as TableRow[] - const paginationInfo = useAtomValueWithSchedule(paginationInfoAtom, { - priority: LOW_PRIORITY, - }) as PaginationResult["paginationInfo"] - - const resetPages = useCallback(() => { - setPagesState({ - pages: [store.createInitialPage(pageSize)], - }) - }, [pageSize, setPagesState, store]) - - useEffect(() => { - if (!resetOnScopeChange) return - resetPages() - }, [resetOnScopeChange, resetPages, scopeId]) - - const totalRows = rows.length - const loadedRowCount = useMemo(() => rows.filter((row) => !row.__isSkeleton).length, [rows]) - - const loadNextPage = useCallback(() => { - if (!paginationInfo.hasMore) { - return - } - const nextCursor = paginationInfo.nextCursor - if (!nextCursor || paginationInfo.isFetching) { - return - } - - const nextOffset = paginationInfo.nextOffset ?? totalRows - const nextWindowing = - paginationInfo.nextWindowing ?? - ({ - next: nextCursor, - order: "ascending", - limit: pageSize, - stop: null, - } as WindowingState) - - if (debugEnabled) { - const skeletonCount = rows.filter((row) => row.__isSkeleton).length - - console.log("[IVT] scheduling next page", { - scopeId, - nextCursor, - nextOffset, - totalRows, - skeletonCount, - }) - } - - scheduleNextPage({ - nextCursor, - nextOffset, - nextWindowing, - totalRows, - }) - }, [ - debugEnabled, - pageSize, - paginationInfo.hasMore, - paginationInfo.isFetching, - paginationInfo.nextCursor, - paginationInfo.nextOffset, - paginationInfo.nextWindowing, - rows, - scheduleNextPage, - scopeId, - totalRows, - ]) - - return { - rows, - rowsAtom: combinedRowsAtom, - loadedRowCount, - totalRows, - loadNextPage, - resetPages, - paginationInfo, - } -} - -export default useInfiniteTablePagination diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts deleted file mode 100644 index 388b4698d8..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useResizableColumns.ts +++ /dev/null @@ -1,221 +0,0 @@ -import {useCallback, useMemo, useRef, useState, type HTMLAttributes} from "react" - -import {ResizableTitle} from "@agenta/ui/table" -import type {ColumnsType, ColumnType} from "antd/es/table" -import {useAtom} from "jotai" - -import {getColumnWidthsAtom} from "../atoms/columnWidths" - -const DEFAULT_MIN_WIDTH = 48 - -type ColumnEntry = ColumnsType[number] -type ColumnWithChildren = ColumnType & {children?: ColumnsType} - -const getColumnChildren = (column: ColumnEntry) => - (column as ColumnWithChildren).children - -const collectLeafColumns = (columns: ColumnsType): ColumnType[] => { - const result: ColumnType[] = [] - const visit = (cols: ColumnsType) => { - cols.forEach((col) => { - const children = getColumnChildren(col) - if (children && children.length) { - visit(children) - } else { - result.push(col as ColumnType) - } - }) - } - visit(columns) - return result -} - -const computeTotalWidth = ( - columns: ColumnsType, - widthOverrides: Record, - minWidth: number, -): number => { - const leafColumns = collectLeafColumns(columns) - return leafColumns.reduce((sum, col) => { - const key = (col?.key ?? col?.dataIndex ?? "") as string - const width = widthOverrides[key] ?? (typeof col.width === "number" ? col.width : minWidth) - return sum + width - }, 0) -} - -export interface UseResizableColumnsArgs { - columns: ColumnsType - enabled?: boolean - minWidth?: number - scopeId?: string | null -} - -export interface UseResizableColumnsResult { - columns: ColumnsType - headerComponents: { - cell: typeof ResizableTitle - } | null - getTotalWidth: (cols?: ColumnsType) => number - isResizing: boolean -} - -export const useResizableColumns = ({ - columns, - enabled = false, - minWidth = DEFAULT_MIN_WIDTH, - scopeId = null, -}: UseResizableColumnsArgs): UseResizableColumnsResult => { - const widthsAtom = useMemo(() => getColumnWidthsAtom(scopeId), [scopeId]) - const [columnWidths, setColumnWidths] = useAtom(widthsAtom) - const [isResizing, setIsResizing] = useState(false) - const columnMetaRef = useRef>({}) - - const commitWidth = useCallback( - (colKey: string, width: number) => { - const metaMinWidth = columnMetaRef.current[colKey]?.minWidth ?? minWidth - const clamped = Math.max(width, metaMinWidth) - setColumnWidths((prev) => { - if (prev[colKey] === clamped) { - return prev - } - return { - ...prev, - [colKey]: clamped, - } - }) - }, - [minWidth, setColumnWidths], - ) - - const handleResize = useCallback( - (colKey: string) => - (_: unknown, {size}: {size: {width: number}}) => { - commitWidth(colKey, size.width) - }, - [commitWidth], - ) - - const handleResizeStart = useCallback(() => { - setIsResizing(true) - }, []) - - const handleResizeStop = useCallback( - (colKey: string) => - (_: unknown, {size}: {size: {width: number}}) => { - commitWidth(colKey, size.width) - setIsResizing(false) - }, - [commitWidth], - ) - - const buildHeaderCellProps = useCallback( - (columnKey: string, width: number | undefined, minValue: number) => - ({ - width, - minWidth: minValue, - onResizeStart: handleResizeStart, - onResize: handleResize(columnKey), - onResizeStop: handleResizeStop(columnKey), - }) as unknown as HTMLAttributes, - [handleResize, handleResizeStart, handleResizeStop], - ) - - const makeColumnsResizable = useCallback( - (cols: ColumnsType): ColumnsType => - cols.map((colEntry) => { - const column = colEntry as ColumnType & { - children?: ColumnsType - } - - const colKey = (column.key ?? - (Array.isArray(column.dataIndex) - ? column.dataIndex.join(".") - : typeof column.dataIndex === "string" - ? column.dataIndex - : Math.random().toString(36))) as string - - const hasChildren = Boolean(column.children && column.children.length) - const isFixed = Boolean(column.fixed) - - if (hasChildren) { - const nextChildren = makeColumnsResizable( - column.children as ColumnsType, - ) - if (isFixed) { - return { - ...column, - key: colKey, - children: nextChildren, - } as typeof colEntry - } - const baseWidth = - typeof column.width === "number" - ? column.width - : typeof column.minWidth === "number" - ? column.minWidth - : undefined - const resolvedMinWidth = - typeof column.minWidth === "number" ? column.minWidth : minWidth - const width = columnWidths[colKey] ?? baseWidth ?? resolvedMinWidth - columnMetaRef.current[colKey] = {minWidth: resolvedMinWidth} - return { - ...column, - key: colKey, - width, - minWidth: resolvedMinWidth, - children: nextChildren, - onHeaderCell: () => - buildHeaderCellProps(colKey, width ?? undefined, resolvedMinWidth), - } as typeof colEntry - } - - if (isFixed) { - delete columnMetaRef.current[colKey] - return { - ...column, - key: colKey, - } as typeof colEntry - } - - const baseWidth = - typeof column.width === "number" - ? column.width - : typeof column.minWidth === "number" - ? column.minWidth - : minWidth - const resolvedMinWidth = - typeof column.minWidth === "number" ? column.minWidth : minWidth - const width = columnWidths[colKey] ?? baseWidth - columnMetaRef.current[colKey] = {minWidth: resolvedMinWidth} - return { - ...column, - key: colKey, - width, - minWidth: resolvedMinWidth, - onHeaderCell: () => buildHeaderCellProps(colKey, width, resolvedMinWidth), - } as typeof colEntry - }), - [buildHeaderCellProps, columnWidths, minWidth], - ) - - const resizableColumns = useMemo(() => { - if (!enabled) return columns - columnMetaRef.current = {} - return makeColumnsResizable(columns) - }, [columns, enabled, makeColumnsResizable]) - - const getTotalWidth = useCallback( - (cols: ColumnsType = resizableColumns) => - computeTotalWidth(cols, columnWidths, minWidth), - [columnWidths, minWidth, resizableColumns], - ) - - return { - columns: resizableColumns, - headerComponents: enabled ? {cell: ResizableTitle} : null, - getTotalWidth, - isResizing, - } -} - -export default useResizableColumns diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx deleted file mode 100644 index 59375e2114..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useRowHeight.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import {useMemo} from "react" - -import {Rows} from "@phosphor-icons/react" -import type {MenuProps} from "antd" -import {atom, useAtom, useAtomValue} from "jotai" -import {atomWithStorage} from "jotai/utils" - -/** - * Row height size options - */ -export type RowHeightSize = "small" | "medium" | "large" - -/** - * Configuration for a single row height option - */ -export interface RowHeightOption { - /** Pixel height for this size */ - height: number - /** Display label in the menu */ - label: string - /** Optional: max lines to show in cells (for text truncation) */ - maxLines?: number -} - -/** - * Full row height configuration for a table - */ -export interface RowHeightConfig { - /** Configuration for each size option */ - sizes: Record - /** Default size to use */ - defaultSize: RowHeightSize - /** LocalStorage key for persisting the preference */ - storageKey: string -} - -/** - * Default row height configuration - * Can be used as-is or customized per table - */ -export const DEFAULT_ROW_HEIGHT_CONFIG: Omit = { - sizes: { - small: {height: 80, label: "Small", maxLines: 4}, - medium: {height: 160, label: "Medium", maxLines: 10}, - large: {height: 280, label: "Large", maxLines: 18}, - }, - defaultSize: "medium", -} - -/** - * Creates a persisted atom for row height preference - * @param storageKey - LocalStorage key for persistence - * @param defaultSize - Default row height size - */ -export function createRowHeightAtom(storageKey: string, defaultSize: RowHeightSize = "medium") { - return atomWithStorage(storageKey, defaultSize) -} - -/** - * Creates a derived atom that returns the pixel height for the current size - * @param sizeAtom - The row height size atom - * @param config - Row height configuration with size definitions - */ -export function createRowHeightPxAtom( - sizeAtom: ReturnType, - config: RowHeightConfig["sizes"], -) { - return atom((get) => { - const size = get(sizeAtom) - return config[size].height - }) -} - -/** - * Creates a derived atom that returns the max lines for the current size - * @param sizeAtom - The row height size atom - * @param config - Row height configuration with size definitions - */ -export function createRowHeightMaxLinesAtom( - sizeAtom: ReturnType, - config: RowHeightConfig["sizes"], -) { - return atom((get) => { - const size = get(sizeAtom) - return config[size].maxLines ?? 10 - }) -} - -/** - * Return type for useRowHeight hook - */ -export interface UseRowHeightResult { - /** Current row height size (small/medium/large) */ - size: RowHeightSize - /** Set the row height size */ - setSize: (size: RowHeightSize) => void - /** Current row height in pixels */ - heightPx: number - /** Max lines to show in cells */ - maxLines: number - /** Menu items for the settings dropdown */ - menuItems: MenuProps["items"] -} - -/** - * Hook to manage row height state and provide menu items for the settings dropdown - * - * @param sizeAtom - Persisted atom for row height size - * @param config - Row height configuration - * @returns Row height state and menu items - * - * @example - * ```tsx - * // In your table component's state file: - * export const myTableRowHeightAtom = createRowHeightAtom("agenta:my-table:row-height") - * - * // In your table component: - * const rowHeight = useRowHeight(myTableRowHeightAtom, { - * sizes: DEFAULT_ROW_HEIGHT_CONFIG.sizes, - * defaultSize: "medium", - * storageKey: "agenta:my-table:row-height" - * }) - * - * - * ``` - */ -export function useRowHeight( - sizeAtom: ReturnType, - config: RowHeightConfig, -): UseRowHeightResult { - const [size, setSize] = useAtom(sizeAtom) - - const heightPx = useMemo(() => config.sizes[size].height, [config.sizes, size]) - const maxLines = useMemo(() => config.sizes[size].maxLines ?? 10, [config.sizes, size]) - - const menuItems = useMemo(() => { - const sizes: RowHeightSize[] = ["small", "medium", "large"] - return [ - { - key: "row-height", - label: "Row height", - icon: , - children: sizes.map((s) => ({ - key: `row-height-${s}`, - label: config.sizes[s].label, - onClick: () => setSize(s), - style: size === s ? {fontWeight: 600} : undefined, - })), - }, - ] - }, [config.sizes, size, setSize]) - - return { - size, - setSize, - heightPx, - maxLines, - menuItems, - } -} - -/** - * Simplified hook when you only need to read the row height values (not set them) - * Useful in child components that just need the current height/maxLines - * - * @param sizeAtom - Persisted atom for row height size - * @param config - Row height configuration (just the sizes) - */ -export function useRowHeightValue( - sizeAtom: ReturnType, - config: RowHeightConfig["sizes"], -) { - const size = useAtomValue(sizeAtom) - - return useMemo( - () => ({ - size, - heightPx: config[size].height, - maxLines: config[size].maxLines ?? 10, - }), - [size, config], - ) -} diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx deleted file mode 100644 index 71572e3360..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useScopedColumnVisibility.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import {useMemo} from "react" - -import type {ColumnsType} from "antd/es/table" - -import {useColumnVisibility} from "../hooks/useColumnVisibility" - -interface Options { - scopeId: string | null - storageKey?: string - defaultHiddenKeys?: string[] -} - -export const useScopedColumnVisibility = ( - columns: ColumnsType, - {scopeId, storageKey, defaultHiddenKeys = []}: Options, -) => { - const scopedStorageKey = useMemo(() => { - if (!storageKey) return undefined - return scopeId ? `${storageKey}::${scopeId}` : storageKey - }, [scopeId, storageKey]) - - return useColumnVisibility(columns, { - storageKey: scopedStorageKey, - defaultHiddenKeys, - }) -} - -export default useScopedColumnVisibility diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts deleted file mode 100644 index 2bc84e02ad..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollConfig.ts +++ /dev/null @@ -1,108 +0,0 @@ -import {useMemo, useRef, type RefObject} from "react" - -import type {TableProps} from "antd/es/table" - -import {shallowEqual} from "../utils/columnUtils" - -interface UseScrollConfigOptions { - containerRef: RefObject - bodyHeight: number | null - containerWidth: number - containerHeight: number - tableHeaderHeight: number | null - computedScrollX: number - tableProps?: TableProps -} - -interface ScrollConfig { - x: number | string | boolean | undefined - y: number | undefined -} - -/** - * Hook to compute scroll configuration for the virtual table - */ -const useScrollConfig = ({ - containerRef, - bodyHeight, - containerWidth, - containerHeight, - tableHeaderHeight, - computedScrollX, - tableProps, -}: UseScrollConfigOptions): ScrollConfig => { - const lastScrollConfigRef = useRef(null) - - const scrollConfig = useMemo(() => { - const resolvedTableProps = tableProps ?? ({} as TableProps) - - if (typeof bodyHeight === "number" && Number.isFinite(bodyHeight)) { - const resolvedScroll = resolvedTableProps.scroll - const resolvedX = - resolvedScroll && typeof resolvedScroll.x !== "undefined" - ? resolvedScroll.x - : containerWidth > 0 - ? containerWidth - : undefined - return {x: resolvedX, y: bodyHeight} - } - - const headerHeight = - (typeof tableHeaderHeight === "number" && Number.isFinite(tableHeaderHeight) - ? tableHeaderHeight - : (containerRef.current?.querySelector(".ant-table-thead") as HTMLElement | null) - ?.offsetHeight) ?? null - - const computedY = Math.max((containerHeight ?? 0) - (headerHeight ?? 0), 0) - const resolvedScroll = resolvedTableProps.scroll - const requestedY = - resolvedScroll && typeof resolvedScroll.y === "number" ? resolvedScroll.y : undefined - const fallbackY = requestedY ?? computedY - let resolvedY = - typeof fallbackY === "number" && Number.isFinite(fallbackY) ? fallbackY : undefined - - const resolvedX = (() => { - const rawX = resolvedScroll?.x - if (typeof rawX === "number" || typeof rawX === "string") { - return rawX - } - if (Number.isFinite(computedScrollX) && computedScrollX > 0) { - return computedScrollX - } - return containerWidth > 0 ? containerWidth : undefined - })() - - if (resolvedY === undefined || resolvedY <= 0) { - const measured = containerHeight ?? 0 - resolvedY = measured > 0 ? Math.max(measured - (headerHeight ?? 0), 0) : 360 - } - - if (resolvedY <= 0) { - resolvedY = 360 - } - - const nextConfig: ScrollConfig = { - x: resolvedX, - y: resolvedY, - } - - const previous = lastScrollConfigRef.current - if (shallowEqual(previous, nextConfig)) { - return previous! - } - lastScrollConfigRef.current = nextConfig - return nextConfig - }, [ - bodyHeight, - computedScrollX, - containerHeight, - containerRef, - containerWidth, - tableHeaderHeight, - tableProps, - ]) - - return scrollConfig -} - -export default useScrollConfig diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts deleted file mode 100644 index 0a82f638a0..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useScrollContainer.ts +++ /dev/null @@ -1,67 +0,0 @@ -import {useEffect, useRef, useState} from "react" - -interface ScrollContainerResult { - scrollContainer: HTMLDivElement | null - visibilityRoot: HTMLDivElement | null -} - -/** - * Hook to detect and track the scrollable container element within the table. - * Optimized to avoid unnecessary state updates during scroll. - */ -const useScrollContainer = ( - containerRef: React.RefObject, - dependencies: {scrollX?: number | string; scrollY?: number; className?: string}, -): ScrollContainerResult => { - const [scrollContainer, setScrollContainer] = useState(null) - const [visibilityRoot, setVisibilityRoot] = useState(null) - // Track last known elements to avoid redundant state updates - const lastScrollContainerRef = useRef(null) - const lastVisibilityRootRef = useRef(null) - - useEffect(() => { - const containerElement = containerRef.current - if (!containerElement) { - if (lastScrollContainerRef.current !== null) { - lastScrollContainerRef.current = null - setScrollContainer(null) - } - if (lastVisibilityRootRef.current !== null) { - lastVisibilityRootRef.current = null - setVisibilityRoot(null) - } - return - } - - const tableBody = containerElement.querySelector(".ant-table-body") ?? null - - const isScrollable = (element: HTMLDivElement | null) => { - if (!element) return false - const style = window.getComputedStyle(element) - const overflowValues = [style.overflow, style.overflowX, style.overflowY] - return overflowValues.some((value) => ["auto", "scroll", "overlay"].includes(value)) - } - - const preferredContainer = isScrollable(tableBody) ? tableBody : null - const nextScrollContainer = preferredContainer ?? containerElement - - // Only update state if the element reference actually changed - if (nextScrollContainer !== lastScrollContainerRef.current) { - lastScrollContainerRef.current = nextScrollContainer - setScrollContainer(nextScrollContainer) - } - - const headerContainer = - containerElement.querySelector(".ant-table-container") ?? - containerElement - - if (headerContainer !== lastVisibilityRootRef.current) { - lastVisibilityRootRef.current = headerContainer - setVisibilityRoot(headerContainer) - } - }, [dependencies.scrollX, dependencies.scrollY, dependencies.className, containerRef]) - - return {scrollContainer, visibilityRoot} -} - -export default useScrollContainer diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts deleted file mode 100644 index 146b65dbfb..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts +++ /dev/null @@ -1,406 +0,0 @@ -import {useCallback, useMemo, useRef, useState, type HTMLAttributes} from "react" - -import {ResizableTitle} from "@agenta/ui/table" -import type {ColumnsType, ColumnType} from "antd/es/table" -import {useAtom} from "jotai" - -import {getColumnWidthsAtom} from "../atoms/columnWidths" - -const DEFAULT_MIN_WIDTH = 150 -const DEFAULT_COLUMN_WIDTH = 200 - -type ColumnEntry = ColumnsType[number] -type ColumnWithChildren = ColumnType & {children?: ColumnsType} - -const getColumnChildren = (column: ColumnEntry) => - (column as ColumnWithChildren).children - -const collectLeafColumns = (columns: ColumnsType): ColumnType[] => { - const result: ColumnType[] = [] - const visit = (cols: ColumnsType) => { - cols.forEach((col) => { - const children = getColumnChildren(col) - if (children && children.length) { - visit(children) - } else { - result.push(col as ColumnType) - } - }) - } - visit(columns) - return result -} - -interface ColumnMeta { - key: string - isFixed: boolean // left/right fixed positioning - hasMaxWidth: boolean // has maxWidth constraint - width: number - minWidth: number - maxWidth?: number -} - -export interface UseSmartResizableColumnsArgs { - columns: ColumnsType - enabled?: boolean - minWidth?: number - scopeId?: string | null - containerWidth: number - selectionColumnWidth: number -} - -export interface UseSmartResizableColumnsResult { - columns: ColumnsType - headerComponents: { - cell: typeof ResizableTitle - } | null - getTotalWidth: (cols?: ColumnsType) => number - isResizing: boolean - /** Whether any column has been manually resized by the user */ - hasUserResizedAny: boolean -} - -/** - * Smart resizable columns hook that intelligently distributes available space - * - * Rules: - * 1. Columns with maxWidth stay at maxWidth (fixed size) - * 2. Columns without maxWidth (flexible) share remaining space proportionally - * 3. On user resize: only resize that column, allow horizontal scroll if needed - * 4. On container resize: redistribute space among flexible columns - */ -export const useSmartResizableColumns = ({ - columns, - enabled = false, - minWidth = DEFAULT_MIN_WIDTH, - scopeId = null, - containerWidth, - selectionColumnWidth, -}: UseSmartResizableColumnsArgs): UseSmartResizableColumnsResult => { - const widthsAtom = useMemo(() => getColumnWidthsAtom(scopeId), [scopeId]) - const [userResizedWidths, setUserResizedWidths] = useAtom(widthsAtom) - const [isResizing, setIsResizing] = useState(false) - const columnMetaRef = useRef>({}) - - // Extract column metadata - const analyzeColumns = useCallback( - (cols: ColumnsType): ColumnMeta[] => { - const leafColumns = collectLeafColumns(cols) - return leafColumns.map((col) => { - const key = (col?.key ?? col?.dataIndex ?? "") as string - const isFixed = Boolean(col.fixed) - const hasMaxWidth = - typeof (col as any).maxWidth === "number" && (col as any).maxWidth > 0 - - const defaultWidth = - typeof col.width === "number" - ? col.width - : typeof col.minWidth === "number" - ? col.minWidth - : DEFAULT_COLUMN_WIDTH - - const resolvedMinWidth = typeof col.minWidth === "number" ? col.minWidth : minWidth - - const maxWidthValue = hasMaxWidth ? (col as any).maxWidth : undefined - - return { - key, - isFixed, - hasMaxWidth, - width: defaultWidth, - minWidth: resolvedMinWidth, - maxWidth: maxWidthValue, - } - }) - }, - [minWidth], - ) - - // Compute smart widths based on available space - // KEY CONSTRAINT: Total width must always >= containerWidth - const computeSmartWidths = useCallback( - (columnsMeta: ColumnMeta[]): Record => { - const result: Record = {} - - // 1. Separate columns by type - const fixedPositionCols = columnsMeta.filter((c) => c.isFixed) - const constrainedCols = columnsMeta.filter((c) => !c.isFixed && c.hasMaxWidth) - const flexibleCols = columnsMeta.filter((c) => !c.isFixed && !c.hasMaxWidth) - - // 2. Calculate fixed widths (these NEVER change) - let fixedWidth = selectionColumnWidth - - // Fixed position columns use their ORIGINAL width (never user-resized) - for (const col of fixedPositionCols) { - result[col.key] = col.width - fixedWidth += col.width - } - - // Constrained columns use their maxWidth - for (const col of constrainedCols) { - const width = col.maxWidth! - result[col.key] = width - fixedWidth += width - } - - // 3. Calculate widths for flexible columns - if (flexibleCols.length === 0) { - return result - } - - // Available space for flexible columns (must be filled!) - const availableForFlexible = Math.max(0, containerWidth - fixedWidth) - - // Separate user-resized and non-resized flexible columns - const userResizedFlexCols = flexibleCols.filter( - (c) => userResizedWidths[c.key] !== undefined, - ) - const nonResizedFlexCols = flexibleCols.filter( - (c) => userResizedWidths[c.key] === undefined, - ) - - // Calculate space taken by user-resized columns - let userResizedTotal = 0 - for (const col of userResizedFlexCols) { - const width = Math.max(userResizedWidths[col.key]!, col.minWidth) - result[col.key] = width - userResizedTotal += width - } - - // Remaining space for non-resized columns - const remainingForNonResized = availableForFlexible - userResizedTotal - - if (nonResizedFlexCols.length === 0) { - // All flexible columns have been user-resized - // If total < available, we need to expand the last resized column - // to maintain the sum constraint - if (userResizedTotal < availableForFlexible && userResizedFlexCols.length > 0) { - const lastCol = userResizedFlexCols[userResizedFlexCols.length - 1] - const deficit = availableForFlexible - userResizedTotal - result[lastCol.key] = (result[lastCol.key] ?? 0) + deficit - } - return result - } - - // Distribute remaining space among non-resized columns - // Use default width as floor to ensure readability, allow horizontal scroll if needed - const totalDefaultWeight = nonResizedFlexCols.reduce((sum, col) => sum + col.width, 0) - - if (remainingForNonResized <= 0) { - // User-resized columns take all space, use default width for others - // This may cause total > container, enabling horizontal scroll - for (const col of nonResizedFlexCols) { - result[col.key] = col.width - } - } else if (remainingForNonResized < totalDefaultWeight) { - // Not enough space for all at default width - use default widths - // and allow horizontal scrolling rather than squeezing columns - for (const col of nonResizedFlexCols) { - result[col.key] = col.width - } - } else { - // Enough space - distribute proportionally. - // - // Widths MUST be integers. The virtual body positions cells by - // the raw width values while the header 's - // rounds each column independently; fractional widths make the - // two diverge and the header/body dividers drift apart left-to- - // right. We floor each column and hand the accumulated rounding - // remainder to the last column so the total still fills exactly. - let distributed = 0 - nonResizedFlexCols.forEach((col, index) => { - if (index === nonResizedFlexCols.length - 1) { - // Last column absorbs the remainder to keep the sum exact. - const remainder = remainingForNonResized - distributed - result[col.key] = Math.max(Math.round(remainder), col.width) - return - } - const proportion = col.width / totalDefaultWeight - // Use default width as floor, not minWidth - const computedWidth = Math.max( - Math.floor(remainingForNonResized * proportion), - col.width, - ) - result[col.key] = computedWidth - distributed += computedWidth - }) - } - - return result - }, - [containerWidth, selectionColumnWidth, userResizedWidths, minWidth], - ) - - const commitWidth = useCallback( - (colKey: string, width: number) => { - const meta = columnMetaRef.current[colKey] - if (!meta) return - - const clamped = Math.max( - width, - meta.minWidth, - meta.maxWidth ? Math.min(width, meta.maxWidth) : width, - ) - - setUserResizedWidths((prev) => { - if (prev[colKey] === clamped) return prev - return { - ...prev, - [colKey]: clamped, - } - }) - }, - [setUserResizedWidths], - ) - - const handleResize = useCallback( - (_colKey: string) => (_: unknown, _size: {size: {width: number}}) => { - // During drag, don't commit to state to avoid jank - // ResizableTitle handles visual feedback - }, - [], - ) - - const handleResizeStart = useCallback(() => { - setIsResizing(true) - }, []) - - const handleResizeStop = useCallback( - (colKey: string) => - (_: unknown, {size}: {size: {width: number}}) => { - // Only commit width when drag ends for smooth performance - commitWidth(colKey, size.width) - setIsResizing(false) - }, - [commitWidth], - ) - - const buildHeaderCellProps = useCallback( - (columnKey: string, width: number | undefined, minValue: number) => - ({ - width, - minWidth: minValue, - onResizeStart: handleResizeStart, - onResize: handleResize(columnKey), - onResizeStop: handleResizeStop(columnKey), - }) as unknown as HTMLAttributes, - [handleResize, handleResizeStart, handleResizeStop], - ) - - const makeColumnsResizable = useCallback( - ( - cols: ColumnsType, - computedWidths: Record, - ): ColumnsType => - cols.map((colEntry) => { - const column = colEntry as ColumnType & { - children?: ColumnsType - } - - const colKey = (column.key ?? - (Array.isArray(column.dataIndex) - ? column.dataIndex.join(".") - : typeof column.dataIndex === "string" - ? column.dataIndex - : Math.random().toString(36))) as string - - const hasChildren = Boolean(column.children && column.children.length) - const isFixed = Boolean(column.fixed) - - if (hasChildren) { - const nextChildren = makeColumnsResizable( - column.children as ColumnsType, - computedWidths, - ) - return { - ...column, - key: colKey, - children: nextChildren, - } as typeof colEntry - } - - const width = computedWidths[colKey] - if (!width) { - // No computed width, use original - return { - ...column, - key: colKey, - } as typeof colEntry - } - - const meta = columnMetaRef.current[colKey] - if (!meta) { - return { - ...column, - key: colKey, - width, - } as typeof colEntry - } - - if (isFixed) { - // Fixed position columns - keep their width but don't make resizable - return { - ...column, - key: colKey, - width, - } as typeof colEntry - } - - return { - ...column, - key: colKey, - width, - minWidth: meta.minWidth, - onHeaderCell: () => buildHeaderCellProps(colKey, width, meta.minWidth), - } as typeof colEntry - }), - [buildHeaderCellProps], - ) - - const resizableColumns = useMemo(() => { - if (!enabled) return columns - - // Analyze columns to build metadata - const meta = analyzeColumns(columns) - columnMetaRef.current = meta.reduce( - (acc, m) => { - acc[m.key] = m - return acc - }, - {} as Record, - ) - - // Compute smart widths - const computedWidths = computeSmartWidths(meta) - - // Apply widths to columns - return makeColumnsResizable(columns, computedWidths) - }, [columns, enabled, analyzeColumns, computeSmartWidths, makeColumnsResizable]) - - const getTotalWidth = useCallback( - (cols: ColumnsType = resizableColumns) => { - const leafColumns = collectLeafColumns(cols) - return leafColumns.reduce((sum, col) => { - const width = typeof col.width === "number" ? col.width : minWidth - return sum + width - }, 0) - }, - [minWidth, resizableColumns], - ) - - // Check if any column has been user-resized - const hasUserResizedAny = useMemo( - () => Object.keys(userResizedWidths).length > 0, - [userResizedWidths], - ) - - return { - columns: resizableColumns, - headerComponents: enabled ? {cell: ResizableTitle} : null, - getTotalWidth, - isResizing, - hasUserResizedAny, - } -} - -export default useSmartResizableColumns diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx deleted file mode 100644 index 1d2848fe1b..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableActions.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import {useCallback} from "react" - -import {useRouter} from "next/router" - -import type {InfiniteTableRowBase} from "../types" - -/** - * Configuration for standard table actions - */ -export interface TableActionsConfig { - /** Base URL for navigation (e.g., "/testsets") */ - baseUrl?: string - - /** Callback when viewing details */ - onView?: (record: T) => void - - /** Callback when creating a new item */ - onCreate?: () => void - - /** Callback when cloning an item */ - onClone?: (record: T) => void - - /** Callback when renaming an item */ - onRename?: (record: T) => void - - /** Callback when deleting an item */ - onDelete?: (record: T) => void - - /** Callback when deleting multiple items */ - onDeleteMany?: (records: T[]) => void - - /** Custom ID extractor (default: record.id or record._id) */ - getRecordId?: (record: T) => string -} - -export interface TableActionsReturn { - /** Navigate to view details */ - handleView: (record: T) => void - - /** Handle clone action */ - handleClone: (record: T) => void - - /** Handle rename action */ - handleRename: (record: T) => void - - /** Handle delete single item */ - handleDelete: (record: T) => void - - /** Handle delete multiple items */ - handleDeleteMany: (records: T[]) => void - - /** Handle create new item */ - handleCreate: () => void -} - -/** - * Hook to create standard CRUD action handlers for tables. - * Reduces boilerplate for common table actions. - * - * @example - * ```tsx - * const actions = useTableActions({ - * baseUrl: `${projectURL}/testsets`, - * onClone: (record) => { - * setMode("clone") - * setEditValues(record) - * setModalOpen(true) - * }, - * onDelete: (record) => { - * setDeleteTargets([record]) - * setDeleteModalOpen(true) - * }, - * }) - * - * // Use in column definitions - * const columns = useTableColumns([ - * { key: "name", title: "Name" }, - * { - * type: "actions", - * items: [ - * { key: "view", onClick: actions.handleView }, - * { key: "clone", onClick: actions.handleClone }, - * { key: "delete", onClick: actions.handleDelete }, - * ], - * }, - * ]) - * ``` - */ -export function useTableActions( - config: TableActionsConfig = {}, -): TableActionsReturn { - const router = useRouter() - const {baseUrl, onView, onCreate, onClone, onRename, onDelete, onDeleteMany, getRecordId} = - config - - const defaultGetId = useCallback( - (record: T): string => { - if (getRecordId) return getRecordId(record) - // Try common ID fields - const id = (record as any).id || (record as any)._id || (record as any).key - if (typeof id === "string") return id - throw new Error("Could not extract ID from record. Provide getRecordId function.") - }, - [getRecordId], - ) - - const handleView = useCallback( - (record: T) => { - if (onView) { - onView(record) - return - } - - // Default behavior: navigate to detail page - if (baseUrl) { - const id = defaultGetId(record) - router.push(`${baseUrl}/${id}`) - } - }, - [baseUrl, defaultGetId, onView, router], - ) - - const handleClone = useCallback( - (record: T) => { - if (onClone) { - onClone(record) - } - }, - [onClone], - ) - - const handleRename = useCallback( - (record: T) => { - if (onRename) { - onRename(record) - } - }, - [onRename], - ) - - const handleDelete = useCallback( - (record: T) => { - if (onDelete) { - onDelete(record) - } - }, - [onDelete], - ) - - const handleDeleteMany = useCallback( - (records: T[]) => { - if (onDeleteMany) { - onDeleteMany(records) - } - }, - [onDeleteMany], - ) - - const handleCreate = useCallback(() => { - if (onCreate) { - onCreate() - } - }, [onCreate]) - - return { - handleView, - handleClone, - handleRename, - handleDelete, - handleDeleteMany, - handleCreate, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts deleted file mode 100644 index 728d7f8940..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableExport.ts +++ /dev/null @@ -1,349 +0,0 @@ -import {useCallback} from "react" - -import type {ColumnsType} from "antd/es/table" - -import type {InfiniteTableRowBase} from "../types" - -export const EXPORT_RESOLVE_SKIP = Symbol("EXPORT_RESOLVE_SKIP") - -const columnIsHidden = ( - column: ColumnsType[number], -): boolean => { - const anyColumn = column as any - if (anyColumn?.visibilityHidden) return true - if (anyColumn?.visibilityLocked === false && anyColumn?.columnProps?.hidden) return true - return false -} - -const flattenColumns = ( - columns: ColumnsType, -): ColumnsType => { - const flat: ColumnsType = [] - columns.forEach((column) => { - if (!column) return - const anyColumn = column as any - if (anyColumn.children && anyColumn.children.length) { - flat.push(...flattenColumns(anyColumn.children as ColumnsType)) - } else { - flat.push(column) - } - }) - return flat -} - -const getColumnIdentifier = (column: ColumnsType[number], index: number) => { - const anyColumn = column as any - const dataIndex = anyColumn?.dataIndex - if (Array.isArray(dataIndex)) { - return dataIndex.join(".") - } - if (dataIndex !== undefined && dataIndex !== null) { - return String(dataIndex) - } - if (anyColumn?.key !== undefined && anyColumn?.key !== null) { - return String(anyColumn.key) - } - return String(index) -} - -const getColumnKey = (column: ColumnsType[number], index: number) => { - const anyColumn = column as any - if (anyColumn?.key !== undefined && anyColumn?.key !== null) { - return String(anyColumn.key) - } - return getColumnIdentifier(column, index) -} - -const getColumnLabel = (column: ColumnsType[number], index: number) => { - const anyColumn = column as any - const title = anyColumn?.exportLabel ?? anyColumn?.exportTitle ?? anyColumn?.title - if (typeof title === "string") return title - if (typeof title === "number") return String(title) - return getColumnIdentifier(column, index) -} - -const getCellText = (value: unknown): string => { - if (value === null || value === undefined) return "" - if (typeof value === "string") return value - if (typeof value === "number" || typeof value === "boolean") return String(value) - return JSON.stringify(value) -} - -const createCsvRow = (values: string[]) => - values - .map((value) => { - if (value.includes(",") || value.includes('"') || value.includes("\n")) { - return `"${value.replace(/"/g, '""')}"` - } - return value - }) - .join(",") - -const getValueFromRowDataIndex = (row: unknown, dataIndex: unknown): unknown => { - if (Array.isArray(dataIndex)) { - return dataIndex.reduce((acc, segment) => { - if (acc === null || acc === undefined) { - return undefined - } - return (acc as any)[segment] - }, row) - } - if ( - typeof dataIndex === "string" || - typeof dataIndex === "number" || - typeof dataIndex === "symbol" - ) { - return (row as any)?.[dataIndex as any] - } - return undefined -} - -const getColumnValueFromMetadata = ({ - column, - columnIndex, - row, -}: TableExportValueArgs): unknown => { - const anyColumn = column as any - - if (typeof anyColumn?.exportValue === "function") { - const value = anyColumn.exportValue(row, column, columnIndex) - if (value !== undefined) { - return value - } - } - - const exportDataIndex = anyColumn?.exportDataIndex ?? anyColumn?.dataIndex - const viaDataIndex = getValueFromRowDataIndex(row, exportDataIndex) - if (viaDataIndex !== undefined) { - return viaDataIndex - } - - if (anyColumn?.key !== undefined && (row as any)?.[anyColumn.key] !== undefined) { - return (row as any)[anyColumn.key] - } - - const identifier = getColumnIdentifier(column, columnIndex) - return (row as any)?.[identifier] -} - -const formatExportValue = ( - value: unknown, - args: TableExportValueArgs, - formatValue?: TableExportOptions["formatValue"], -): string => { - const anyColumn = args.column as any - if (typeof anyColumn?.exportFormatter === "function") { - const formatted = anyColumn.exportFormatter(value, args.row, args.column, args.columnIndex) - if (formatted !== undefined) { - return formatted - } - } - - if (formatValue) { - const formatted = formatValue(value, args) - if (formatted !== undefined) { - return formatted - } - } - - return getCellText(value) -} - -const filterSkeletonRows = ( - rows: Row[], - includeSkeletonRows?: boolean, -) => { - if (includeSkeletonRows) return rows - return rows.filter((row) => !(row as any)?.__isSkeleton) -} - -export interface TableExportColumnContext { - column: ColumnsType[number] - columnIndex: number -} - -export interface TableExportValueArgs< - Row extends InfiniteTableRowBase, -> extends TableExportColumnContext { - row: Row -} - -export interface TableExportOptions { - filename?: string - isColumnExportable?: (context: TableExportColumnContext) => boolean - getValue?: (args: TableExportValueArgs) => unknown - formatValue?: (value: unknown, args: TableExportValueArgs) => string | undefined - includeSkeletonRows?: boolean - beforeExport?: (rows: Row[]) => void | Row[] | Promise - resolveValue?: (args: TableExportResolveArgs) => unknown | Promise - resolveColumnLabel?: (context: TableExportColumnContext) => string | undefined - columnsOverride?: ColumnsType -} - -export interface TableExportParams< - Row extends InfiniteTableRowBase, -> extends TableExportOptions { - columns: ColumnsType - rows: Row[] -} - -export interface TableExportResolveArgs< - Row extends InfiniteTableRowBase, -> extends TableExportValueArgs { - rowIndex: number - columnKey: string - columnIdentifier: string - currentValue: unknown -} - -export const useTableExport = () => { - return useCallback(async (params: TableExportParams) => { - const { - columns, - rows, - filename = "table-export.csv", - isColumnExportable, - getValue, - formatValue, - includeSkeletonRows, - beforeExport, - resolveValue, - resolveColumnLabel, - } = params - - if (!columns.length || !rows.length) return - - let filteredRows = filterSkeletonRows(rows, includeSkeletonRows) - if (!filteredRows.length) return - - if (beforeExport) { - const result = await beforeExport(filteredRows) - // If beforeExport returns rows, use those (allows beforeExport to load more data) - if (result && Array.isArray(result)) { - filteredRows = filterSkeletonRows(result as Row[], includeSkeletonRows) - if (!filteredRows.length) return - } - } - - const flatColumns = flattenColumns(columns).filter((column, index) => { - if (columnIsHidden(column)) return false - const anyColumn = column as any - if (anyColumn?.exportEnabled === false) return false - if (isColumnExportable) { - return isColumnExportable({column, columnIndex: index}) - } - return true - }) - if (!flatColumns.length) return - - const headers = flatColumns.map((column, index) => { - const override = resolveColumnLabel?.({column, columnIndex: index}) - return override ?? getColumnLabel(column, index) - }) - - const csvRows = [createCsvRow(headers)] - - // Build cell metadata for all cells - interface CellMeta { - rowIndex: number - columnIndex: number - column: (typeof flatColumns)[number] - row: Row - columnKey: string - columnIdentifier: string - initialValue: unknown - } - const cellMetas: CellMeta[] = [] - - for (let rowIndex = 0; rowIndex < filteredRows.length; rowIndex += 1) { - const row = filteredRows[rowIndex] - for (let columnIndex = 0; columnIndex < flatColumns.length; columnIndex += 1) { - const column = flatColumns[columnIndex] - const columnKey = getColumnKey(column, columnIndex) - const columnIdentifier = getColumnIdentifier(column, columnIndex) - const context: TableExportValueArgs = {column, columnIndex, row} - const override = getValue !== undefined ? getValue(context) : undefined - const initialValue = - override !== undefined ? override : getColumnValueFromMetadata(context) - - cellMetas.push({ - rowIndex, - columnIndex, - column, - row, - columnKey, - columnIdentifier, - initialValue, - }) - } - } - - // Resolve all cell values at once - the underlying batchers handle API batching - const resolvedValues: unknown[] = new Array(cellMetas.length) - - if (resolveValue) { - const allPromises = cellMetas.map((meta, i) => { - const context: TableExportValueArgs = { - column: meta.column, - columnIndex: meta.columnIndex, - row: meta.row, - } - return Promise.resolve( - resolveValue({ - ...context, - rowIndex: meta.rowIndex, - columnKey: meta.columnKey, - columnIdentifier: meta.columnIdentifier, - currentValue: meta.initialValue, - }), - ).then((resolved: unknown) => ({index: i, value: resolved})) - }) - - const allResults = await Promise.all(allPromises) - for (const {index, value} of allResults) { - if (value === EXPORT_RESOLVE_SKIP) { - resolvedValues[index] = cellMetas[index].initialValue - } else if (value !== undefined) { - resolvedValues[index] = value - } else { - resolvedValues[index] = cellMetas[index].initialValue - } - } - } else { - // No resolver, use initial values - for (let i = 0; i < cellMetas.length; i++) { - resolvedValues[i] = cellMetas[i].initialValue - } - } - - // Build CSV rows from resolved values - const numColumns = flatColumns.length - for (let rowIndex = 0; rowIndex < filteredRows.length; rowIndex += 1) { - const values: string[] = [] - for (let columnIndex = 0; columnIndex < numColumns; columnIndex += 1) { - const cellIndex = rowIndex * numColumns + columnIndex - const meta = cellMetas[cellIndex] - const rawValue = resolvedValues[cellIndex] - const context: TableExportValueArgs = { - column: meta.column, - columnIndex: meta.columnIndex, - row: meta.row, - } - values.push(formatExportValue(rawValue, context, formatValue)) - } - csvRows.push(createCsvRow(values)) - } - - const blob = new Blob([csvRows.join("\n")], {type: "text/csv;charset=utf-8;"}) - const url = URL.createObjectURL(blob) - const link = document.createElement("a") - link.href = url - link.setAttribute("download", filename) - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - setTimeout(() => URL.revokeObjectURL(url), 500) - }, []) -} - -export default useTableExport diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts deleted file mode 100644 index 81d5cf8c47..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableHeaderHeight.ts +++ /dev/null @@ -1,52 +0,0 @@ -import {useLayoutEffect, useState, type RefObject} from "react" - -import type {ColumnsType, TableProps} from "antd/es/table" - -interface UseTableHeaderHeightOptions { - containerRef: RefObject - columns: ColumnsType - dataSource: RecordType[] - components?: TableProps["components"] -} - -/** - * Hook to observe and track table header height using ResizeObserver - */ -const useTableHeaderHeight = ({ - containerRef, - columns, - dataSource, - components, -}: UseTableHeaderHeightOptions) => { - const [tableHeaderHeight, setTableHeaderHeight] = useState(null) - - useLayoutEffect(() => { - const container = containerRef.current - if (!container) { - setTableHeaderHeight(null) - return - } - const headerEl = - container.querySelector(".ant-table-thead") ?? - container.querySelector("table thead") - if (!headerEl) { - setTableHeaderHeight(null) - return - } - const updateHeight = () => { - const nextHeight = headerEl.getBoundingClientRect().height - setTableHeaderHeight((prev) => { - if (prev === nextHeight) return prev - return Number.isFinite(nextHeight) ? nextHeight : prev - }) - } - const observer = new ResizeObserver(() => updateHeight()) - observer.observe(headerEl) - updateHeight() - return () => observer.disconnect() - }, [columns, containerRef, dataSource, components]) - - return tableHeaderHeight -} - -export default useTableHeaderHeight diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts deleted file mode 100644 index f8855e71e0..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableKeyboardShortcuts.ts +++ /dev/null @@ -1,662 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from "react" -import type {Key, MutableRefObject, RefObject} from "react" - -import type { - InfiniteVirtualTableKeyboardRowShortcuts, - InfiniteVirtualTableKeyboardSelectionShortcuts, - InfiniteVirtualTableKeyboardShortcuts, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, -} from "../types" - -interface UseTableKeyboardShortcutsParams { - containerRef: RefObject - dataSource: RecordType[] - rowKey: InfiniteVirtualTableProps["rowKey"] - rowSelection?: InfiniteVirtualTableRowSelection - keyboardShortcuts?: InfiniteVirtualTableKeyboardShortcuts - active: boolean -} - -interface SelectableEntry { - key: Key - record: RecordType - position: number -} - -interface NormalizedSelectionShortcuts { - enabled: boolean - navigation: boolean - range: boolean - selectAll: boolean - clear: boolean -} - -interface NormalizedRowShortcuts { - enabled: boolean - autoHighlightFirstRow: boolean - highlightOnHover: boolean - highlightClassName: string - scrollIntoViewOnChange: boolean - toggleSelectionWithSpace: boolean - onHighlightChange?: (payload: {key: Key | null; record: RecordType | null}) => void - onOpen?: (payload: {key: Key; record: RecordType}) => void - onDelete?: (payload: { - key: Key - record: RecordType - selected: boolean - selection: Key[] - }) => void - onExport?: (payload: {key: Key | null; record: RecordType | null; selection: Key[]}) => void -} - -interface TableShortcutResult { - getRowProps?: ( - record: RecordType, - index: number, - ) => { - className?: string - onMouseEnter?: () => void - } -} - -const DEFAULT_HIGHLIGHT_CLASS = "ivt-row--highlighted" - -const isInteractiveTarget = (element: HTMLElement | null) => { - if (!element) return false - if (element.isContentEditable) return true - const tag = element.tagName.toLowerCase() - if (tag === "input" || tag === "textarea" || tag === "select") { - return true - } - const role = element.getAttribute("role") - if (role && ["textbox", "combobox", "menuitem", "button"].includes(role)) { - return true - } - return Boolean(element.closest("[data-ivt-shortcuts='ignore']")) -} - -const normalizeSelectionShortcuts = ( - enabled: boolean, - selection?: boolean | InfiniteVirtualTableKeyboardSelectionShortcuts, -): NormalizedSelectionShortcuts => { - const config = selection ?? {} - const selectionEnabled = - typeof config === "object" ? (config.enabled ?? true) : config !== false - return { - enabled: enabled && selectionEnabled, - navigation: typeof config === "object" ? (config.navigation ?? true) : config !== false, - range: typeof config === "object" ? (config.range ?? true) : config !== false, - selectAll: typeof config === "object" ? (config.selectAll ?? true) : config !== false, - clear: typeof config === "object" ? (config.clear ?? true) : config !== false, - } -} - -const normalizeRowShortcuts = ( - config?: InfiniteVirtualTableKeyboardRowShortcuts, -): NormalizedRowShortcuts => ({ - enabled: config?.enabled ?? true, - autoHighlightFirstRow: config?.autoHighlightFirstRow ?? false, - highlightOnHover: config?.highlightOnHover ?? true, - highlightClassName: config?.highlightClassName ?? DEFAULT_HIGHLIGHT_CLASS, - scrollIntoViewOnChange: config?.scrollIntoViewOnChange ?? true, - toggleSelectionWithSpace: config?.toggleSelectionWithSpace ?? true, - onHighlightChange: config?.onHighlightChange, - onOpen: config?.onOpen, - onDelete: config?.onDelete, - onExport: config?.onExport, -}) - -const normalizeKeyboardShortcutConfig = ( - config?: InfiniteVirtualTableKeyboardShortcuts, -) => { - const enabled = config?.enabled ?? true - return { - enabled, - selection: normalizeSelectionShortcuts(enabled, config?.selection), - rows: normalizeRowShortcuts(config?.rows), - } -} - -const resolveRowKey = ( - rowKey: InfiniteVirtualTableProps["rowKey"], - record: RecordType, - index: number, -): Key | null => { - if (typeof rowKey === "function") { - const value = rowKey(record, index) - return value === undefined || value === null ? null : (value as Key) - } - if (typeof rowKey === "string") { - const value = (record as Record)[rowKey] - return value === undefined || value === null ? null : (value as Key) - } - const fallback = (record as Record).key ?? index - return (fallback as Key) ?? null -} - -const usePointerScopeTracker = ( - containerRef: RefObject, - active: boolean, - enabled: boolean, -): MutableRefObject => { - const scopeRef = useRef(false) - - useEffect(() => { - if (!enabled) return - const handlePointerDown = (event: PointerEvent) => { - const container = containerRef.current - if (!container || !active) { - scopeRef.current = false - return - } - scopeRef.current = container.contains(event.target as Node) - } - document.addEventListener("pointerdown", handlePointerDown, true) - return () => document.removeEventListener("pointerdown", handlePointerDown, true) - }, [active, containerRef, enabled]) - - useEffect(() => { - if (!enabled) return - const container = containerRef.current - if (!container) return - const handlePointerEnter = () => { - if (!active) return - scopeRef.current = true - } - const handlePointerLeave = (event: PointerEvent) => { - const related = event.relatedTarget as Node | null - if (related && container.contains(related)) return - scopeRef.current = false - } - container.addEventListener("pointerenter", handlePointerEnter, true) - container.addEventListener("pointerleave", handlePointerLeave, true) - return () => { - container.removeEventListener("pointerenter", handlePointerEnter, true) - container.removeEventListener("pointerleave", handlePointerLeave, true) - } - }, [active, containerRef, enabled]) - - useEffect(() => { - if (!active) { - scopeRef.current = false - } - }, [active]) - - return scopeRef -} - -const dedupeKeys = (keys: Key[]) => { - const seen = new Set() - const result: Key[] = [] - keys.forEach((key) => { - if (seen.has(key)) return - seen.add(key) - result.push(key) - }) - return result -} - -const escapeSelector = (value: Key) => { - const str = String(value) - if ( - typeof window !== "undefined" && - typeof window.CSS !== "undefined" && - typeof window.CSS.escape === "function" - ) { - return window.CSS.escape(str) - } - return str.replace(/['"\\]/g, "\\$&") -} - -function useTableKeyboardShortcuts({ - containerRef, - dataSource, - rowKey, - rowSelection, - keyboardShortcuts, - active, -}: UseTableKeyboardShortcutsParams): TableShortcutResult { - const resolvedConfig = useMemo( - () => normalizeKeyboardShortcutConfig(keyboardShortcuts), - [keyboardShortcuts], - ) - const selectionShortcuts = resolvedConfig.selection - const rowShortcuts = resolvedConfig.rows - const hasSelectionControls = Boolean(rowSelection && rowSelection.onChange) - const selectionEnabled = selectionShortcuts.enabled && hasSelectionControls - - const navigableEntries = useMemo[]>(() => { - const entries: SelectableEntry[] = [] - dataSource.forEach((record, index) => { - const key = resolveRowKey(rowKey, record, index) - if (key === null || key === undefined) return - if ((record as any)?.__isSkeleton) return - const position = entries.length - entries.push({key, record, position}) - }) - return entries - }, [dataSource, rowKey]) - - const navigableMap = useMemo(() => { - const map = new Map>() - navigableEntries.forEach((entry) => { - map.set(entry.key, entry) - }) - return map - }, [navigableEntries]) - - const selectableEntries = useMemo[]>(() => { - if (!selectionEnabled || !rowSelection) return [] - const entries: SelectableEntry[] = [] - dataSource.forEach((record, index) => { - const key = resolveRowKey(rowKey, record, index) - if (key === null || key === undefined) return - const checkboxProps = rowSelection.getCheckboxProps?.(record) ?? {} - if (checkboxProps.disabled) return - const position = entries.length - entries.push({key, record, position}) - }) - return entries - }, [dataSource, rowKey, rowSelection, selectionEnabled]) - - const keyToEntry = useMemo(() => { - const map = new Map>() - selectableEntries.forEach((entry) => { - map.set(entry.key, entry) - }) - return map - }, [selectableEntries]) - - const selectedKeys = useMemo(() => { - if (!selectionEnabled || !rowSelection) return [] - return (rowSelection.selectedRowKeys ?? []).filter((key) => keyToEntry.has(key)) - }, [keyToEntry, rowSelection, selectionEnabled]) - - const selectedKeySet = useMemo(() => new Set(selectedKeys), [selectedKeys]) - const allowsMultipleSelection = rowSelection?.type !== "radio" - - const anchorKeyRef = useRef(null) - const activeKeyRef = useRef(null) - const highlightEntryRef = useRef | null>(null) - const [highlightedKey, setHighlightedKey] = useState(null) - - useEffect(() => { - if (!selectionEnabled) { - anchorKeyRef.current = null - activeKeyRef.current = null - return - } - if (!selectedKeys.length) { - anchorKeyRef.current = null - activeKeyRef.current = null - return - } - const lastKey = selectedKeys[selectedKeys.length - 1] - activeKeyRef.current = lastKey - if (!anchorKeyRef.current || !selectedKeySet.has(anchorKeyRef.current)) { - anchorKeyRef.current = lastKey - } - }, [selectedKeySet, selectedKeys, selectionEnabled]) - - const pointerScopeRef = usePointerScopeTracker(containerRef, active, resolvedConfig.enabled) - - const triggerSelectionChange = useCallback( - (nextKeys: Key[], opts?: {anchorKey?: Key | null; activeKey?: Key | null}) => { - if (!rowSelection?.onChange) return - const normalizedKeys = dedupeKeys( - nextKeys.filter((key) => keyToEntry.has(key)), - ) as Key[] - const rows = normalizedKeys.map((key) => keyToEntry.get(key)!.record) - rowSelection.onChange(normalizedKeys, rows) - if (opts) { - if ("anchorKey" in opts) { - anchorKeyRef.current = opts.anchorKey ?? null - } - if ("activeKey" in opts) { - activeKeyRef.current = opts.activeKey ?? null - } - } - }, - [keyToEntry, rowSelection], - ) - - const handleSelectAll = useCallback(() => { - if (!selectionEnabled || !selectionShortcuts.selectAll) return - if (!allowsMultipleSelection) return - if (!selectableEntries.length) return - const keys = selectableEntries.map((entry) => entry.key) - const firstKey = keys[0] - const lastKey = keys[keys.length - 1] - triggerSelectionChange(keys, {anchorKey: firstKey, activeKey: lastKey}) - }, [ - allowsMultipleSelection, - selectableEntries, - selectionEnabled, - selectionShortcuts.selectAll, - triggerSelectionChange, - ]) - - const handleClearSelection = useCallback(() => { - if (!selectionEnabled || !selectionShortcuts.clear) return - triggerSelectionChange([], {anchorKey: null, activeKey: null}) - }, [selectionEnabled, selectionShortcuts.clear, triggerSelectionChange]) - - const handleMove = useCallback( - (direction: 1 | -1, extend: boolean) => { - if (!selectionEnabled || !selectionShortcuts.navigation) return - if (!selectableEntries.length) return - - const currentActiveKey = activeKeyRef.current - const activeEntry = currentActiveKey ? keyToEntry.get(currentActiveKey) : undefined - let nextPosition: number - if (!activeEntry) { - nextPosition = direction > 0 ? 0 : selectableEntries.length - 1 - } else { - nextPosition = activeEntry.position + direction - if (nextPosition < 0 || nextPosition >= selectableEntries.length) { - return - } - } - const nextEntry = selectableEntries[nextPosition] - if (!nextEntry) return - - const shouldExtend = - extend && - allowsMultipleSelection && - selectionShortcuts.range && - selectableEntries.length - - if (!shouldExtend) { - triggerSelectionChange([nextEntry.key], { - anchorKey: nextEntry.key, - activeKey: nextEntry.key, - }) - return - } - - const anchorKey = anchorKeyRef.current ?? nextEntry.key - const anchorEntry = keyToEntry.get(anchorKey) - if (!anchorEntry) { - triggerSelectionChange([nextEntry.key], { - anchorKey: nextEntry.key, - activeKey: nextEntry.key, - }) - return - } - - const start = Math.min(anchorEntry.position, nextPosition) - const end = Math.max(anchorEntry.position, nextPosition) - const rangeKeys = selectableEntries.slice(start, end + 1).map((entry) => entry.key) - triggerSelectionChange(rangeKeys, { - anchorKey: anchorEntry.key, - activeKey: nextEntry.key, - }) - }, - [ - allowsMultipleSelection, - keyToEntry, - selectableEntries, - selectionEnabled, - selectionShortcuts.navigation, - selectionShortcuts.range, - triggerSelectionChange, - ], - ) - - const scrollRowIntoView = useCallback( - (key: Key) => { - if (!rowShortcuts.scrollIntoViewOnChange) return - const container = containerRef.current - if (!container) return - const selector = escapeSelector(key) - const row = - container.querySelector(`[data-row-key="${selector}"]`) ?? - container.querySelector(`[data-row-key='${selector}']`) - row?.scrollIntoView({block: "nearest"}) - }, - [containerRef, rowShortcuts.scrollIntoViewOnChange], - ) - - const setHighlightEntry = useCallback( - (entry: SelectableEntry | null, options?: {scroll?: boolean}) => { - highlightEntryRef.current = entry - const nextKey = entry?.key ?? null - setHighlightedKey((current) => (current === nextKey ? current : nextKey)) - rowShortcuts.onHighlightChange?.({key: nextKey, record: entry?.record ?? null}) - if (options?.scroll && entry?.key) { - scrollRowIntoView(entry.key) - } - }, - [rowShortcuts, scrollRowIntoView], - ) - - useEffect(() => { - if (!rowShortcuts.enabled) return - if (highlightEntryRef.current && navigableMap.has(highlightEntryRef.current.key)) { - return - } - if (!rowShortcuts.autoHighlightFirstRow) { - setHighlightEntry(null) - return - } - const firstEntry = navigableEntries[0] ?? null - setHighlightEntry(firstEntry ?? null, {scroll: false}) - }, [ - navigableEntries, - navigableMap, - rowShortcuts.autoHighlightFirstRow, - rowShortcuts.enabled, - setHighlightEntry, - ]) - - const moveHighlight = useCallback( - (direction: 1 | -1) => { - if (!rowShortcuts.enabled || !navigableEntries.length) return false - const current = highlightEntryRef.current - if (!current) { - const target = - direction > 0 - ? navigableEntries[0] - : navigableEntries[navigableEntries.length - 1] - setHighlightEntry(target, {scroll: true}) - return Boolean(target) - } - const nextIndex = current.position + direction - if (nextIndex < 0 || nextIndex >= navigableEntries.length) { - return false - } - const nextEntry = navigableEntries[nextIndex] - setHighlightEntry(nextEntry, {scroll: true}) - return true - }, - [navigableEntries, rowShortcuts.enabled, setHighlightEntry], - ) - - const toggleHighlightedSelection = useCallback(() => { - if (!rowShortcuts.enabled || !rowShortcuts.toggleSelectionWithSpace) return false - if (!rowSelection?.onChange) return false - const entry = highlightEntryRef.current - if (!entry) return false - const isSelected = selectedKeySet.has(entry.key) - const nextKeys = isSelected - ? selectedKeys.filter((key) => key !== entry.key) - : [...selectedKeys, entry.key] - triggerSelectionChange(nextKeys) - return true - }, [ - rowSelection, - rowShortcuts.enabled, - rowShortcuts.toggleSelectionWithSpace, - selectedKeySet, - selectedKeys, - triggerSelectionChange, - ]) - - const openHighlightedRow = useCallback(() => { - if (!rowShortcuts.enabled || !rowShortcuts.onOpen) return false - const entry = highlightEntryRef.current - if (!entry) return false - rowShortcuts.onOpen({key: entry.key, record: entry.record}) - return true - }, [rowShortcuts]) - - const deleteHighlightedRow = useCallback(() => { - if (!rowShortcuts.enabled || !rowShortcuts.onDelete) return false - const entry = highlightEntryRef.current - if (!entry) return false - const isSelected = selectedKeySet.has(entry.key) - rowShortcuts.onDelete({ - key: entry.key, - record: entry.record, - selected: isSelected, - selection: selectedKeys, - }) - return true - }, [rowShortcuts, selectedKeySet, selectedKeys]) - - const getRowProps = useCallback( - (record: RecordType, index: number) => { - if (!rowShortcuts.enabled) return undefined - const key = resolveRowKey(rowKey, record, index) - if (key === null || key === undefined) return undefined - const isHighlighted = highlightedKey !== null && key === highlightedKey - const props: Record = {"data-ivt-row-key": key} - if (isHighlighted) { - props.className = rowShortcuts.highlightClassName - } - if (rowShortcuts.highlightOnHover !== false) { - props.onMouseEnter = () => { - const entry = navigableMap.get(key) - if (entry) { - setHighlightEntry(entry) - } - } - } - return props - }, - [highlightedKey, navigableMap, rowKey, rowShortcuts, setHighlightEntry], - ) - - useEffect(() => { - if (!resolvedConfig.enabled || (!selectionEnabled && !rowShortcuts.enabled)) return - const handleKeyDown = (event: KeyboardEvent) => { - if (!active) return - if (!pointerScopeRef.current) return - const target = event.target as HTMLElement | null - if (isInteractiveTarget(target)) { - return - } - - const isArrowKey = event.key === "ArrowDown" || event.key === "ArrowUp" - const direction = event.key === "ArrowDown" ? 1 : -1 - - if (isArrowKey) { - let handled = false - if (rowShortcuts.enabled) { - handled = moveHighlight(direction as 1 | -1) || handled - } - if (selectionShortcuts.navigation) { - handleMove(direction as 1 | -1, event.shiftKey) - handled = true - } - if (handled) { - event.preventDefault() - return - } - } - - const isModifier = event.metaKey || event.ctrlKey - if ( - selectionShortcuts.selectAll && - allowsMultipleSelection && - isModifier && - event.key.toLowerCase() === "a" - ) { - event.preventDefault() - handleSelectAll() - return - } - - if (event.key === "Escape") { - let handled = false - if (selectionShortcuts.clear && selectedKeys.length) { - handleClearSelection() - handled = true - } else if ( - rowShortcuts.enabled && - highlightEntryRef.current && - !selectedKeySet.has(highlightEntryRef.current.key) - ) { - setHighlightEntry(null) - handled = true - } - if (handled) { - event.preventDefault() - return - } - } - - if (rowShortcuts.enabled && (event.key === " " || event.code === "Space")) { - if (toggleHighlightedSelection()) { - event.preventDefault() - } - return - } - - if ( - rowShortcuts.enabled && - rowShortcuts.onExport && - isModifier && - (event.key === "Enter" || event.key.toLowerCase() === "e") - ) { - rowShortcuts.onExport({ - key: highlightEntryRef.current?.key ?? null, - record: highlightEntryRef.current?.record ?? null, - selection: selectedKeys, - }) - event.preventDefault() - return - } - - if (rowShortcuts.enabled && event.key === "Enter") { - if (openHighlightedRow()) { - event.preventDefault() - } - return - } - - if (rowShortcuts.enabled && event.key === "Backspace") { - if (deleteHighlightedRow()) { - event.preventDefault() - } - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [ - active, - allowsMultipleSelection, - deleteHighlightedRow, - handleClearSelection, - handleMove, - handleSelectAll, - moveHighlight, - openHighlightedRow, - pointerScopeRef, - resolvedConfig.enabled, - rowShortcuts.enabled, - selectionEnabled, - selectionShortcuts.clear, - selectionShortcuts.navigation, - selectionShortcuts.selectAll, - toggleHighlightedSelection, - ]) - - return { - getRowProps: rowShortcuts.enabled ? getRowProps : undefined, - } -} - -export default useTableKeyboardShortcuts diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx deleted file mode 100644 index 2c3c610497..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableManager.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import type {Key, MouseEvent, ReactNode, RefObject} from "react" -import {useCallback, useEffect, useMemo, useRef, useState} from "react" - -import {Grid, Input} from "antd" -import type {ColumnsType} from "antd/es/table" -import clsx from "clsx" -import {atom, useAtom} from "jotai" -import type {WritableAtom} from "jotai" - -import type {InfiniteDatasetStore} from "../createInfiniteDatasetStore" -import type { - TableScopeConfig, - TableFeaturePagination, - InfiniteVirtualTableFeatureProps, - TableDeleteConfig, - TableExportConfig, -} from "../features/InfiniteVirtualTableFeatureShell" -import type { - InfiniteTableRowBase, - InfiniteVirtualTableProps, - InfiniteVirtualTableRowSelection, -} from "../types" - -import useTableExport from "./useTableExport" - -/** Stable no-op atom used when no external search atom is provided (hooks can't be conditional) */ -const dummySearchAtom = atom("") - -const INTERACTIVE_SELECTOR = - "button, a, input, textarea, select, [role='button'], [role='menuitem'], [role='checkbox'], " + - ".ant-btn, .ant-checkbox, .ant-checkbox-input, .ant-checkbox-inner, .ant-checkbox-wrapper, " + - ".ant-select, .ant-dropdown-trigger, .ant-table-selection-column, .ag-table-actions-cell" - -/** - * Returns true when the click originated from an interactive element (button, link, - * dropdown, checkbox, etc.) and should not bubble up to the row navigation handler. - */ -export const shouldIgnoreRowClick = (event: MouseEvent): boolean => { - const target = event.target as HTMLElement | null - return Boolean(target?.closest(INTERACTIVE_SELECTOR)) -} - -/** Configuration for built-in search. When provided, the hook manages search state internally. */ -export interface TableSearchConfig { - /** Placeholder text (default: "Search") */ - placeholder?: string - /** Custom className for the search input (default: "max-w-[320px]") */ - className?: string - /** Whether search is disabled */ - disabled?: boolean - /** External Jotai atom to sync search term with (for cross-component access) */ - atom?: WritableAtom -} - -export interface UseTableManagerConfig { - /** The dataset store for this table */ - datasetStore: InfiniteDatasetStore - - /** Unique scope ID for this table instance */ - scopeId: string - - /** Number of items per page (default: 50) */ - pageSize?: number - - /** Row height in pixels (default: 48) */ - rowHeight?: number - - /** Callback when a row is clicked */ - onRowClick?: (record: T) => void - - /** - * Built-in search configuration. When provided, the hook manages search state - * and renders a search input in the filters slot of shellProps. - * Pass `true` for defaults, or an object for customization. - */ - search?: TableSearchConfig | boolean - - /** Dependencies that should trigger pagination reset (e.g., search term) */ - searchDeps?: any[] - - /** Whether rows should be clickable (default: true) */ - clickableRows?: boolean - - /** Custom className for rows */ - rowClassName?: string | ((record: T) => string) - - /** Storage key for column visibility persistence */ - columnVisibilityStorageKey?: string | null - - /** Enable infinite scroll (default: true) */ - enableInfiniteScroll?: boolean - - /** Callback when bulk delete is triggered */ - onBulkDelete?: (records: T[]) => void - - /** Label for delete button (default: "Delete") */ - deleteLabel?: string - - /** Tooltip when delete is disabled (default: "Select items to delete") */ - deleteDisabledTooltip?: string - - /** Label for export button (default: "Export CSV") */ - exportLabel?: string - - /** Tooltip when export is disabled (default: "Select items to export") */ - exportDisabledTooltip?: string - - /** Filename for CSV export (default: "table-export.csv") */ - exportFilename?: string -} - -export interface UseTableManagerReturn { - /** Pagination state and controls */ - pagination: ReturnType["hooks"]["usePagination"]> - - /** Current rows from pagination */ - rows: T[] - - /** Selected row keys */ - selectedRowKeys: Key[] - - /** Update selected row keys */ - setSelectedRowKeys: (keys: Key[] | ((prev: Key[]) => Key[])) => void - - /** Row selection configuration for the table */ - rowSelection: InfiniteVirtualTableRowSelection - - /** Table props configuration */ - tableProps: InfiniteVirtualTableProps["tableProps"] - - /** Table scope configuration */ - tableScope: TableScopeConfig - - /** Pagination configuration for FeatureShell */ - tablePagination: TableFeaturePagination - - /** Get currently selected records */ - getSelectedRecords: () => T[] - - /** Clear selection */ - clearSelection: () => void - - /** Whether running on narrow screen (< lg breakpoint) */ - isNarrowScreen: boolean - - /** Delete action config for the shell */ - deleteAction: TableDeleteConfig | undefined - - /** Export action config for the shell */ - exportAction: TableExportConfig | undefined - - /** Handler to export a single row */ - handleExportRow: (record: T) => Promise - - /** Whether a row is currently being exported */ - rowExportingKey: string | null - - /** Ref to store current columns for export */ - columnsRef: RefObject | null> - - /** Search term value (only meaningful when search config is provided) */ - searchTerm: string - - /** Search term setter (only meaningful when search config is provided) */ - setSearchTerm: (value: string) => void - - /** Spread these props directly to InfiniteVirtualTableFeatureShell */ - shellProps: Pick< - InfiniteVirtualTableFeatureProps, - | "datasetStore" - | "tableScope" - | "pagination" - | "rowSelection" - | "tableProps" - | "deleteAction" - | "exportAction" - | "useSettingsDropdown" - | "rowKey" - | "filters" - > -} - -/** - * Hook to manage common table setup and reduce boilerplate. - * - * Consolidates: - * - Pagination setup and auto-reset - * - Row selection state and config - * - Row click handlers with smart ignore logic - * - Table props with sensible defaults - * - Scope and pagination configs - * - * @example - * ```tsx - * const table = useTableManager({ - * datasetStore: testsetsDatasetStore, - * scopeId: "testsets-page", - * pageSize: 50, - * onRowClick: (record) => router.push(`/testsets/${record._id}`), - * searchDeps: [searchTerm], - * }) - * - * return ( - * - * ) - * ``` - */ -export function useTableManager({ - datasetStore, - scopeId, - pageSize = 50, - rowHeight = 48, - onRowClick, - search, - searchDeps: externalSearchDeps = [], - clickableRows = true, - rowClassName, - columnVisibilityStorageKey, - enableInfiniteScroll = true, - onBulkDelete, - deleteLabel = "Delete", - deleteDisabledTooltip = "Select items to delete", - exportLabel = "Export CSV", - exportDisabledTooltip = "Select items to export", - exportFilename = "table-export.csv", -}: UseTableManagerConfig): UseTableManagerReturn { - // Responsive breakpoints - const screens = Grid.useBreakpoint() - const isNarrowScreen = !screens.lg - - // Normalize search config - const searchConfig = search === true ? {} : search || undefined - const searchAtom = searchConfig?.atom - - // Built-in search state (local or atom-backed) - const [localSearchTerm, setLocalSearchTerm] = useState("") - const [atomSearchTerm, setAtomSearchTerm] = useAtom(searchAtom || dummySearchAtom) - - const searchTerm = searchConfig ? (searchAtom ? atomSearchTerm : localSearchTerm) : "" - const setSearchTerm = useCallback( - (value: string) => { - if (searchAtom) { - setAtomSearchTerm(value) - } else { - setLocalSearchTerm(value) - } - }, - [searchAtom, setAtomSearchTerm], - ) - - // Merge built-in search deps with any external searchDeps - const searchDeps = searchConfig ? [searchTerm, ...externalSearchDeps] : externalSearchDeps - - // Pagination - const pagination = datasetStore.hooks.usePagination({ - scopeId, - pageSize, - resetOnScopeChange: false, - }) - - const {rows, loadNextPage, resetPages} = pagination - - // Selection state - const [selectedRowKeys, setSelectedRowKeys] = useState([]) - - // Export state - const [rowExportingKey, setRowExportingKey] = useState(null) - const tableExport = useTableExport() - const columnsRef = useRef | null>(null) - - // Auto-reset pagination when search dependencies change (skip initial mount) - const searchDepsInitialized = useRef(false) - useEffect(() => { - if (!searchDepsInitialized.current) { - searchDepsInitialized.current = true - return - } - if (searchDeps.length > 0) { - resetPages() - } - }, [resetPages, ...searchDeps]) - - // Row selection config - const rowSelection = useMemo>( - () => ({ - type: "checkbox" as const, - selectedRowKeys, - onChange: (keys: Key[]) => { - setSelectedRowKeys(keys) - }, - getCheckboxProps: (record: T) => ({ - disabled: Boolean(record.__isSkeleton), - }), - columnWidth: 48, - fixed: true, - }), - [selectedRowKeys], - ) - - // Row click handlers - const buildRowHandlers = useCallback( - (record: T) => { - const isNavigable = clickableRows && !record.__isSkeleton - const customClass = - typeof rowClassName === "function" ? rowClassName(record) : rowClassName - - return { - onClick: (event: MouseEvent) => { - if (!isNavigable) return - if (shouldIgnoreRowClick(event)) return - onRowClick?.(record) - }, - className: clsx(customClass, { - "opacity-60 animate-pulse": record.__isSkeleton, - }), - style: { - cursor: isNavigable ? "pointer" : "default", - height: rowHeight, - minHeight: rowHeight, - } as React.CSSProperties, - } - }, - [clickableRows, onRowClick, rowClassName, rowHeight], - ) - - // Table props with defaults - const tableProps = useMemo( - () => ({ - size: "small" as const, - sticky: true, - bordered: true, - virtual: true, - tableLayout: "fixed" as const, - onRow: buildRowHandlers, - }), - [buildRowHandlers], - ) - - // Table scope config - const tableScope = useMemo( - () => ({ - scopeId, - pageSize, - enableInfiniteScroll, - columnVisibilityStorageKey: columnVisibilityStorageKey ?? undefined, - }), - [scopeId, pageSize, enableInfiniteScroll, columnVisibilityStorageKey], - ) - - // Pagination config for FeatureShell - const tablePagination = useMemo>( - () => ({ - rows, - loadNextPage, - resetPages, - }), - [rows, loadNextPage, resetPages], - ) - - // Helper to get selected records - const getSelectedRecords = useCallback( - () => rows.filter((record) => selectedRowKeys.includes(record.key)), - [rows, selectedRowKeys], - ) - - // Helper to clear selection - const clearSelection = useCallback(() => { - setSelectedRowKeys([]) - }, []) - - // Delete action config - shell handles button rendering and narrow screen behavior - const deleteAction = useMemo( - () => - onBulkDelete - ? { - onDelete: () => onBulkDelete(getSelectedRecords()), - disabled: !selectedRowKeys.length, - disabledTooltip: deleteDisabledTooltip, - label: deleteLabel, - } - : undefined, - [ - onBulkDelete, - selectedRowKeys.length, - getSelectedRecords, - deleteDisabledTooltip, - deleteLabel, - ], - ) - - // Export action config - shell handles button rendering and narrow screen behavior - const exportAction = useMemo( - () => ({ - disabled: !selectedRowKeys.length, - disabledTooltip: exportDisabledTooltip, - label: exportLabel, - }), - [selectedRowKeys.length, exportDisabledTooltip, exportLabel], - ) - - // Handler to export a single row - const handleExportRow = useCallback( - async (record: T) => { - if (!record || record.__isSkeleton || !record.key) return - const snapshot = columnsRef.current - if (!snapshot?.length) { - console.warn("[useTableManager] Cannot export row without columns") - return - } - const sanitizedKey = String(record.key).replace(/[^a-zA-Z0-9-_]+/g, "-") - setRowExportingKey(String(record.key)) - try { - await tableExport({ - columns: snapshot, - rows: [record], - filename: exportFilename.replace(".csv", `-${sanitizedKey}.csv`), - }) - } catch (error) { - console.error("[useTableManager] Failed to export row", error) - } finally { - setRowExportingKey((current) => (current === String(record.key) ? null : current)) - } - }, - [tableExport, exportFilename], - ) - - // Row key extractor - const rowKeyExtractor = useCallback((record: T) => record.key, []) - - // Built-in search node - const searchNode = useMemo(() => { - if (!searchConfig) return undefined - return ( - setSearchTerm(e.target.value)} - placeholder={searchConfig.placeholder ?? "Search"} - allowClear - disabled={searchConfig.disabled} - className={clsx("w-full", searchConfig.className ?? "max-w-[320px]")} - /> - ) - }, [searchConfig, searchTerm, setSearchTerm]) - - // Shell props to spread directly to InfiniteVirtualTableFeatureShell - const shellProps = useMemo( - () => ({ - datasetStore, - tableScope, - pagination: tablePagination, - rowSelection, - tableProps, - deleteAction, - exportAction, - useSettingsDropdown: isNarrowScreen, - rowKey: rowKeyExtractor, - filters: searchNode, - }), - [ - datasetStore, - tableScope, - tablePagination, - rowSelection, - tableProps, - deleteAction, - exportAction, - isNarrowScreen, - rowKeyExtractor, - searchNode, - ], - ) - - return { - pagination, - rows, - selectedRowKeys, - setSelectedRowKeys, - rowSelection, - tableProps, - tableScope, - tablePagination, - getSelectedRecords, - clearSelection, - isNarrowScreen, - deleteAction, - exportAction, - handleExportRow, - rowExportingKey, - columnsRef, - searchTerm, - setSearchTerm, - shellProps, - } -} diff --git a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts b/web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts deleted file mode 100644 index 1d131934e7..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/hooks/useTableRowSelection.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {useMemo} from "react" - -import type {TableProps} from "antd/es/table" - -import type {InfiniteVirtualTableRowSelection} from "../types" - -/** - * Hook to transform InfiniteVirtualTableRowSelection into Ant Design TableProps rowSelection - */ -const useTableRowSelection = ( - rowSelection: InfiniteVirtualTableRowSelection | undefined, -): TableProps["rowSelection"] | undefined => { - return useMemo(() => { - if (!rowSelection) return undefined - - const { - selectedRowKeys, - onChange, - getCheckboxProps, - columnWidth, - type = "checkbox", - fixed, - columnTitle, - renderCell, - onCell: customOnCell, - } = rowSelection - - return { - type, - columnWidth: columnWidth ?? 48, - selectedRowKeys, - fixed, - columnTitle, - onCell: (record: RecordType, index?: number) => { - const baseProps = { - align: "center" as const, - className: "flex flex-col items-center justify-center", - } - if (customOnCell) { - const customProps = customOnCell(record, index) - return { - ...baseProps, - ...customProps, - className: `${baseProps.className} ${customProps.className || ""}`.trim(), - } - } - return baseProps - }, - onChange, - getCheckboxProps, - renderCell, - } - }, [rowSelection]) -} - -export default useTableRowSelection diff --git a/web/oss/src/components/InfiniteVirtualTable/index.ts b/web/oss/src/components/InfiniteVirtualTable/index.ts deleted file mode 100644 index 617a45fd6a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -export {createInfiniteTableStore} from "./createInfiniteTableStore" -export type {InfiniteTableStore} from "./createInfiniteTableStore" -export {createInfiniteDatasetStore} from "./createInfiniteDatasetStore" -export {createTableColumns} from "./columns/createTableColumns" -export { - createTextCell, - createComponentCell, - createStatusCell, - createActionsCell, - createViewportAwareCell, - createColumnVisibilityAwareCell, -} from "./columns/cells" -export * from "./columns/types" -export {default as useInfiniteTablePagination} from "./hooks/useInfiniteTablePagination" -export {useTableManager, shouldIgnoreRowClick} from "./hooks/useTableManager" -export type { - UseTableManagerConfig, - UseTableManagerReturn, - TableSearchConfig, -} from "./hooks/useTableManager" -export {useTableActions} from "./hooks/useTableActions" -export type {TableActionsConfig, TableActionsReturn} from "./hooks/useTableActions" -export { - createStandardColumns, - createTextColumn, - createDateColumn, - createUserColumn, - createActionsColumn, -} from "./columns/createStandardColumns" -export type { - StandardColumnDef, - TextColumnDef, - DateColumnDef, - UserColumnDef, - ActionsColumnDef, - ActionItem, -} from "./columns/createStandardColumns" -// Table store helpers -export {createTableRowHelpers, createSimpleTableStore, createTableMetaAtom} from "./helpers" -export type { - TableRowHelpersConfig, - CreateSkeletonRowParams, - MergeRowParams, - TableRowHelpers, - DateRangeFilter, - BaseTableMeta, - SimpleTableStoreConfig, - SimpleTableStore, -} from "./helpers" -export { - default as InfiniteVirtualTable, - InfiniteVirtualTableStoreProvider, - useVirtualTableScrollContainer, - useColumnVisibilityControls, -} from "./InfiniteVirtualTable" -export {default as ColumnVisibilityTrigger} from "./components/ColumnVisibilityTrigger" -export {default as ColumnVisibilityMenuTrigger} from "./components/columnVisibility/ColumnVisibilityMenuTrigger" -export {default as ColumnVisibilityPopoverContent} from "./components/columnVisibility/ColumnVisibilityPopoverContent" -export {default as TableSettingsDropdown} from "./components/columnVisibility/TableSettingsDropdown" -export {default as FiltersPopoverTrigger} from "./components/filters/FiltersPopoverTrigger" -export {default as TableShell} from "./components/TableShell" -export {default as TableDescription} from "./components/TableDescription" -export type {TableDescriptionProps} from "./components/TableDescription" -export {InfiniteVirtualTableFeatureShell, useInfiniteTableFeaturePagination} from "./features" -export type { - TableScopeConfig, - TableFeaturePagination, - TableFeatureExportOptions, - InfiniteVirtualTableFeatureProps, - TableTabItem, - TableTabsConfig, - TableDeleteConfig, - TableExportConfig, -} from "./features" -export {default as ColumnVisibilityHeader} from "./components/ColumnVisibilityHeader" -export {default as ColumnVisibilityProvider} from "./providers/ColumnVisibilityProvider" -export {useColumnVisibilityContext} from "./context/ColumnVisibilityContext" -export {useExpandableRows} from "./hooks/useExpandableRows" -export {useEditableTable} from "./hooks/useEditableTable" -export type { - EditableTableColumn, - EditableTableConfig, - EditableTableState, - EditableTableActions, -} from "./hooks/useEditableTable" -export { - useRowHeight, - useRowHeightValue, - createRowHeightAtom, - createRowHeightPxAtom, - createRowHeightMaxLinesAtom, - DEFAULT_ROW_HEIGHT_CONFIG, -} from "./hooks/useRowHeight" -export type { - RowHeightSize, - RowHeightOption, - RowHeightConfig, - UseRowHeightResult, -} from "./hooks/useRowHeight" -export * from "./types" -export type {ExpandableRowConfig, ExpandIconRenderProps} from "./types" -export type {VisibilityRegistrationHandler} from "./components/ColumnVisibilityHeader" diff --git a/web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx b/web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx deleted file mode 100644 index 42a5f89f97..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/providers/ColumnVisibilityProvider.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import {useMemo, type PropsWithChildren} from "react" - -import type {VisibilityRegistrationHandler} from "../components/ColumnVisibilityHeader" -import ColumnVisibilityContext, { - defaultColumnVisibilityContextValue, - type ColumnVisibilityContextValue, -} from "../context/ColumnVisibilityContext" -import type { - ColumnVisibilityState, - ColumnVisibilityMenuRenderer, - ColumnVisibilityMenuTriggerRenderer, -} from "../types" - -interface ColumnVisibilityProviderProps extends PropsWithChildren { - controls: ColumnVisibilityState | null - registerHeader?: VisibilityRegistrationHandler | null - version?: number - renderMenuContent?: ColumnVisibilityMenuRenderer - renderMenuTrigger?: ColumnVisibilityMenuTriggerRenderer - scopeId?: string | null -} - -const ColumnVisibilityProvider = ({ - controls, - registerHeader = null, - version = 0, - renderMenuContent, - renderMenuTrigger, - scopeId = null, - children, -}: ColumnVisibilityProviderProps) => { - const value = useMemo>( - () => ({ - controls: - controls ?? - (defaultColumnVisibilityContextValue.controls as ColumnVisibilityState), - registerHeader, - version, - renderMenuContent, - renderMenuTrigger, - scopeId, - }), - [controls, registerHeader, renderMenuContent, renderMenuTrigger, scopeId, version], - ) - - return ( - - {children} - - ) -} - -export default ColumnVisibilityProvider diff --git a/web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx b/web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx deleted file mode 100644 index 5c77fb77f4..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/providers/InfiniteVirtualTableStoreProvider.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type {ReactNode} from "react" -import {useRef} from "react" - -import {useQueryClient} from "@tanstack/react-query" -import {Provider} from "jotai" -import {useHydrateAtoms} from "jotai/react/utils" -import {createStore} from "jotai/vanilla" -import type {Store} from "jotai/vanilla/store" -import {queryClientAtom} from "jotai-tanstack-query" - -export const InfiniteVirtualTableStoreHydrator = ({ - queryClient, - children, -}: { - queryClient: ReturnType - children: ReactNode -}) => { - useHydrateAtoms([[queryClientAtom, queryClient]]) - return <>{children} -} - -export const InfiniteVirtualTableStoreProvider = ({ - store, - children, -}: { - store?: Store - children: ReactNode -}) => { - const queryClient = useQueryClient() - const storeRef = useRef(store ?? createStore()) - return ( - - - {children} - - - ) -} diff --git a/web/oss/src/components/InfiniteVirtualTable/types.ts b/web/oss/src/components/InfiniteVirtualTable/types.ts deleted file mode 100644 index f2d5c28dd3..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/types.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type {Key, ReactNode} from "react" - -import type {ColumnsType, TableProps} from "antd/es/table" -import type {Getter} from "jotai" -import type {Store} from "jotai/vanilla/store" - -import type {VisibilityRegistrationHandler} from "./components/ColumnVisibilityHeader" - -export interface WindowingState { - next: string | null - oldest?: string | null - newest?: string | null - stop?: string | null - order?: string | null - limit?: number | null -} - -export interface InfiniteTablePage { - offset: number - limit: number - cursor: string | null - windowing: WindowingState | null -} - -export interface InfiniteTableRowBase { - key: React.Key - __isSkeleton: boolean - [key: string]: unknown -} - -export interface InfiniteTableFetchParams { - scopeId: string | null - cursor: string | null - limit: number - offset: number - windowing: WindowingState | null - meta: TMeta | undefined - get: Getter -} - -export interface InfiniteTableFetchResult { - rows: ApiRow[] - totalCount: number | null - hasMore: boolean - nextOffset: number | null - nextCursor: string | null - nextWindowing: WindowingState | null -} - -export interface ColumnViewportVisibilityEvent { - scopeId: string | null - columnKey: string - visible: boolean -} - -export interface ColumnVisibilityState { - allKeys: Key[] - leafKeys: Key[] - hiddenKeys: Key[] - setHiddenKeys: (keys: Key[]) => void - isHidden: (key: Key) => boolean - showColumn: (key: Key) => void - hideColumn: (key: Key) => void - toggleColumn: (key: Key) => void - toggleTree: (key: Key) => void - reset: () => void - visibleColumns: ColumnsType - columnTree: ColumnTreeNode[] - version: number -} - -export interface ColumnTreeNode { - key: Key - label: string - titleNode?: ReactNode - checked: boolean - indeterminate: boolean - children: ColumnTreeNode[] -} - -export interface ColumnVisibilityNodeMeta { - title?: ReactNode - searchValues?: (string | undefined)[] - icon?: ReactNode -} - -export type ColumnVisibilityNodeMetaResolver = ( - node: ColumnTreeNode, -) => ColumnVisibilityNodeMeta | Promise - -export interface ColumnVisibilityMenuRendererContext { - scopeId: string | null - onExport?: () => void - isExporting?: boolean -} - -export type ColumnVisibilityMenuRenderer = ( - controls: ColumnVisibilityState, - close: () => void, - context: ColumnVisibilityMenuRendererContext, -) => ReactNode - -export type ColumnVisibilityMenuTriggerRenderer = ( - controls: ColumnVisibilityState, - context: ColumnVisibilityMenuRendererContext, -) => ReactNode - -export interface ColumnVisibilityConfig { - storageKey?: string - defaultHiddenKeys?: Key[] - viewportTrackingEnabled?: boolean - viewportMargin?: string - viewportExitDebounceMs?: number - onStateChange?: (state: ColumnVisibilityState) => void - onViewportVisibilityChange?: ( - payload: ColumnViewportVisibilityEvent | ColumnViewportVisibilityEvent[], - ) => void - onContextChange?: (payload: { - controls: ColumnVisibilityState - registerHeader: VisibilityRegistrationHandler | null - version: number - }) => void - renderMenuContent?: ColumnVisibilityMenuRenderer - /** - * Custom renderer for the menu trigger (gear icon). - * When provided, replaces the default gear icon popover trigger. - * Useful for rendering a dropdown menu instead of a popover. - */ - renderMenuTrigger?: ColumnVisibilityMenuTriggerRenderer - resolveNodeMeta?: ColumnVisibilityNodeMetaResolver -} - -export interface InfiniteVirtualTableRowSelection { - type?: "checkbox" | "radio" - selectedRowKeys: Key[] - onChange?: (selectedRowKeys: Key[], selectedRows: RecordType[]) => void - getCheckboxProps?: (record: RecordType) => { - disabled?: boolean - indeterminate?: boolean - } - columnWidth?: number - fixed?: boolean - /** Custom title for the selection column header (replaces checkbox) */ - columnTitle?: React.ReactNode - /** Custom render for the selection cell */ - renderCell?: ( - value: boolean, - record: RecordType, - index: number, - originNode: React.ReactNode, - ) => React.ReactNode - /** Custom cell props for the selection column */ - onCell?: (record: RecordType, index?: number) => React.TdHTMLAttributes -} - -export interface InfiniteVirtualTableKeyboardSelectionShortcuts { - enabled?: boolean - navigation?: boolean - range?: boolean - selectAll?: boolean - clear?: boolean -} - -export interface InfiniteVirtualTableKeyboardRowShortcuts { - enabled?: boolean - autoHighlightFirstRow?: boolean - highlightOnHover?: boolean - highlightClassName?: string - scrollIntoViewOnChange?: boolean - toggleSelectionWithSpace?: boolean - onHighlightChange?: (payload: {key: Key | null; record: RecordType | null}) => void - onOpen?: (payload: {key: Key; record: RecordType}) => void - onDelete?: (payload: { - key: Key - record: RecordType - selected: boolean - selection: Key[] - }) => void - onExport?: (payload: {key: Key | null; record: RecordType | null; selection: Key[]}) => void -} - -export interface InfiniteVirtualTableKeyboardShortcuts { - enabled?: boolean - selection?: boolean | InfiniteVirtualTableKeyboardSelectionShortcuts - rows?: InfiniteVirtualTableKeyboardRowShortcuts -} - -export interface ResizableColumnsConfig { - minWidth?: number -} - -/** - * Expand icon render props passed to custom renderers - */ -export interface ExpandIconRenderProps { - expanded: boolean - onExpand: () => void - record: RecordType - loading: boolean -} - -/** - * Configuration for expandable rows in InfiniteVirtualTable. - * Provides a minimal API for consumers to define how rows expand. - */ -export interface ExpandableRowConfig { - /** - * Function to fetch child data when a row is expanded. - * Should return a promise that resolves to an array of child items. - */ - fetchChildren: (record: RecordType) => Promise - - /** - * Render function for the expanded content. - * Receives the parent record and its fetched children. - */ - renderExpanded: ( - record: RecordType, - children: ChildType[], - loading: boolean, - error: Error | null, - ) => ReactNode - - /** - * Optional: Determine if a row is expandable. - * Defaults to true for all rows if not provided. - */ - isExpandable?: (record: RecordType) => boolean - - /** - * Optional: Custom expand icon renderer. - */ - expandIcon?: (props: ExpandIconRenderProps) => ReactNode - - /** - * Optional: Width of the expand column (default: 48) - * Set to 0 when using showExpandIconInCell to hide the column. - */ - columnWidth?: number - - /** - * Optional: Fixed position of expand column (default: undefined) - */ - fixed?: "left" | "right" - - /** - * Optional: Cache fetched children to avoid re-fetching on collapse/expand. - * Default: true - */ - cacheChildren?: boolean - - /** - * Optional: Accordion mode - only one row can be expanded at a time. - * Default: false - */ - accordion?: boolean - - /** - * When true, the expand icon column is hidden and consumers should - * render the expand icon within their own cell using renderExpandIcon. - * Default: false - */ - showExpandIconInCell?: boolean -} - -export interface InfiniteVirtualTableProps { - columns: ColumnsType - dataSource: RecordType[] - loadMore: () => void - rowKey: TableProps["rowKey"] - active?: boolean - scrollThreshold?: number - containerClassName?: string - tableClassName?: string - tableProps?: Omit, "columns" | "dataSource" | "onScroll" | "pagination"> - rowSelection?: InfiniteVirtualTableRowSelection - resizableColumns?: boolean | ResizableColumnsConfig - columnVisibility?: ColumnVisibilityConfig - /** - * When true, disables the built-in guard that prevents row-click navigation - * from firing when the click originates from an interactive element (button, - * checkbox, dropdown, etc.). Defaults to false — the guard is on by default. - */ - disableInteractiveClickGuard?: boolean - onColumnToggle?: (payload: { - scopeId: string | null - columnKey: string - visible: boolean - }) => void - scopeId?: string | null - beforeTable?: React.ReactNode - useIsolatedStore?: boolean - store?: Store | null - bodyHeight?: number | null - onHeaderHeightChange?: (height: number | null) => void - keyboardShortcuts?: InfiniteVirtualTableKeyboardShortcuts - /** - * Configuration for expandable rows. - * When provided, rows can be expanded to show child content. - */ - expandable?: ExpandableRowConfig - /** - * Ref to access the underlying Ant Design Table instance. - * Useful for programmatic scrolling via `tableRef.current?.scrollTo({ index })`. - */ - tableRef?: React.RefObject<{ - scrollTo: (config: {index: number; align?: "top" | "bottom" | "auto"}) => void - } | null> -} diff --git a/web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts b/web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts deleted file mode 100644 index 5bdc247e3a..0000000000 --- a/web/oss/src/components/InfiniteVirtualTable/utils/columnUtils.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type {Key} from "react" - -import type {ColumnsType} from "antd/es/table" - -/** - * Collects all column keys that have `fixed` property set - */ -export const collectFixedColumnKeys = ( - columns: ColumnsType, -): string[] => { - const keys = new Set() - const visit = (cols: ColumnsType) => { - cols.forEach((column) => { - const typedColumn = column as any - if (!typedColumn) return - const columnKey = typedColumn.key - const isFixed = Boolean(typedColumn.fixed) - if (isFixed && columnKey !== undefined && columnKey !== null) { - keys.add(String(columnKey)) - } - if (typedColumn.children && typedColumn.children.length) { - visit(typedColumn.children as ColumnsType) - } - }) - } - visit(columns) - return Array.from(keys) -} - -/** - * Converts a Key to string or null - */ -export const toColumnKey = (key: Key | undefined): string | null => - key === undefined || key === null ? null : String(key) - -/** - * Builds a map of parent column keys to their descendant leaf keys - */ -export const buildColumnDescendantMap = ( - columns: ColumnsType, -): Map => { - const map = new Map() - const gatherDescendants = (column: ColumnsType[number]): string[] => { - const typedColumn = column as any - if (!typedColumn) return [] - const key = toColumnKey(typedColumn.key) - const childColumns = Array.isArray(typedColumn.children) - ? (typedColumn.children as ColumnsType) - : null - if (!childColumns || childColumns.length === 0) { - return key ? [key] : [] - } - const descendantLeaves = childColumns.flatMap((child) => gatherDescendants(child)) - if (key && descendantLeaves.length) { - map.set(key, Array.from(new Set(descendantLeaves))) - } - return descendantLeaves.length ? descendantLeaves : key ? [key] : [] - } - columns.forEach((column) => gatherDescendants(column)) - return map -} - -/** - * Merges two optional event handlers into one - */ -export const mergeHandlers = < - T extends (...args: any[]) => void | undefined, - U extends (...args: any[]) => void | undefined, ->( - first?: T, - second?: U, -): ((...args: Parameters) => void) | ((...args: Parameters) => void) | undefined => { - if (!first && !second) { - return undefined - } - if (!first) { - return second as any - } - if (!second) { - return first as any - } - return ((...args: any[]) => { - first(...(args as Parameters)) - second(...(args as Parameters)) - }) as any -} - -/** - * Shallow equality check for objects - */ -export const shallowEqual = (a: Record | null, b: Record): boolean => { - if (a === b) return true - if (!a || !b) return false - const keysA = Object.keys(a) - const keysB = Object.keys(b) - if (keysA.length !== keysB.length) return false - for (const key of keysA) { - if (a[key] !== b[key]) return false - } - return true -} diff --git a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx index dd141c8830..5699e58766 100644 --- a/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx +++ b/web/oss/src/components/Playground/Components/PlaygroundHeader/index.tsx @@ -16,6 +16,7 @@ import {type WorkflowRevisionSelectionResult} from "@agenta/entity-ui/selection" import {useEnrichedEvaluatorOnlyAdapter as useEvaluatorOnlyAdapter} from "@agenta/entity-ui/selection" import {playgroundController} from "@agenta/playground" import {usePlaygroundLayout} from "@agenta/playground-ui/hooks" +import {openWorkflowRevisionDrawerAtom} from "@agenta/playground-ui/workflow-revision-drawer" import {bgColors, textColors} from "@agenta/ui" import {VersionBadge} from "@agenta/ui/components/presentational" import {CloseOutlined, DownOutlined, MoreOutlined} from "@ant-design/icons" @@ -28,7 +29,6 @@ import dynamic from "next/dynamic" import EvaluatorTemplateDropdown from "@/oss/components/Evaluators/components/EvaluatorTemplateDropdown" import useCustomWorkflowConfig from "@/oss/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig" import {routerAppIdAtom} from "@/oss/state/app/selectors/app" -import {openEvaluatorDrawerAtom} from "@/oss/state/evaluator/evaluatorDrawerStore" import {writePlaygroundSelectionToQuery} from "@/oss/state/url/playground" import {currentWorkflowAtom, currentWorkflowContextAtom} from "@/oss/state/workflow" import {workspaceMemberByIdFamily} from "@/oss/state/workspace/atoms/selectors" @@ -275,7 +275,7 @@ const PlaygroundHeader: React.FC = ({className, ...divPro setTemplateDropdownOpen(true) }, []) - const openEvaluatorDrawer = useSetAtom(openEvaluatorDrawerAtom) + const openEvaluatorDrawer = useSetAtom(openWorkflowRevisionDrawerAtom) const playgroundStore = useStore() // The root node's `label` can be a raw entity UUID (URL-hydrated nodes get // `label: entityId`), so build the display label from entity data instead: @@ -371,7 +371,7 @@ const PlaygroundHeader: React.FC = ({className, ...divPro openEvaluatorDrawer({ entityId: localId, - mode: "create", + context: "evaluator-create", isolatedPlayground: true, initialAppSelection: currentAppSelection, postCreateNavigation: "stay", diff --git a/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx b/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx index 2e19582e4b..ac25da0e52 100644 --- a/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx +++ b/web/oss/src/components/Playground/Components/TestsetDropdown/TestsetPreviewPanelWrapper.tsx @@ -16,10 +16,10 @@ import {useCallback, useEffect, useMemo, useState} from "react" import type {PreviewPanelRenderProps} from "@agenta/playground-ui/components" import {EnhancedModal, ModalContent, ModalFooter} from "@agenta/ui" import {message} from "@agenta/ui/app-message" +import {useRowHeight} from "@agenta/ui/table" import {PlusOutlined} from "@ant-design/icons" import {Button, Input, Typography} from "antd" -import {useRowHeight} from "@/oss/components/InfiniteVirtualTable" import TestcaseEditDrawer from "@/oss/components/SharedDrawers/TestcaseDrawer" import {TestcasesTableShell} from "@/oss/components/TestcasesTableNew/components/TestcasesTableShell" import {useTestcasesTable} from "@/oss/components/TestcasesTableNew/hooks/useTestcasesTable" diff --git a/web/oss/src/components/Playground/Components/TestsetDropdown/index.tsx b/web/oss/src/components/Playground/Components/TestsetDropdown/index.tsx index b86d8caed5..99af607088 100644 --- a/web/oss/src/components/Playground/Components/TestsetDropdown/index.tsx +++ b/web/oss/src/components/Playground/Components/TestsetDropdown/index.tsx @@ -16,6 +16,10 @@ import type { CommitSubmitResult, } from "@agenta/entity-ui" import {EntityCommitModal} from "@agenta/entity-ui" +import { + toTestsetTraceReference, + type TestsetTraceReference, +} from "@agenta/evaluations/state/evalRun" import {playgroundController} from "@agenta/playground" import { executionByMessageIdAtomFamily, @@ -44,7 +48,6 @@ import {atom, useAtom, useAtomValue, useSetAtom, useStore} from "jotai" import dynamic from "next/dynamic" import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" -import {toTestsetTraceReference, type TestsetTraceReference} from "@/oss/lib/traces/traceUtils" import {saveNewTestsetAtom} from "@/oss/state/entities/testset/mutations" import {projectIdAtom} from "@/oss/state/project/selectors/project" diff --git a/web/oss/src/components/References/ReferenceLabels.tsx b/web/oss/src/components/References/ReferenceLabels.tsx index bd983598f5..34609ca99d 100644 --- a/web/oss/src/components/References/ReferenceLabels.tsx +++ b/web/oss/src/components/References/ReferenceLabels.tsx @@ -1,6 +1,7 @@ import {memo, useMemo} from "react" import {getWorkflowTypeColor, workflowMolecule} from "@agenta/entities/workflow" +import type {ReferenceTone} from "@agenta/shared/utils" import {Skeleton, Typography} from "antd" import type {TooltipPlacement} from "antd/es/tooltip" import clsx from "clsx" @@ -15,7 +16,6 @@ import { previewTestsetReferenceAtomFamily, queryReferenceAtomFamily, } from "./atoms/entityReferences" -import type {ReferenceTone} from "./referenceColors" import ReferenceTag from "./ReferenceTag" const {Text} = Typography diff --git a/web/oss/src/components/References/ReferenceTag.tsx b/web/oss/src/components/References/ReferenceTag.tsx index c61093022f..22af8abfd6 100644 --- a/web/oss/src/components/References/ReferenceTag.tsx +++ b/web/oss/src/components/References/ReferenceTag.tsx @@ -1,5 +1,6 @@ import {useEffect, useRef, useState, type ComponentType, type ReactNode} from "react" +import {getReferenceToneColors, type ReferenceTone} from "@agenta/shared/utils" import { ArrowSquareOut, BracketsCurly, @@ -20,8 +21,6 @@ import {useRouter} from "next/router" import {copyToClipboard} from "@/oss/lib/helpers/copyToClipboard" -import {getReferenceToneColors, type ReferenceTone} from "./referenceColors" - /** * Identifier set behind a reference chip. Feeds the slug crossfade on hover, * the version pill, and the identifier hovercard. diff --git a/web/oss/src/components/References/atoms/entityReferences.ts b/web/oss/src/components/References/atoms/entityReferences.ts index f0395a594f..637a7a9086 100644 --- a/web/oss/src/components/References/atoms/entityReferences.ts +++ b/web/oss/src/components/References/atoms/entityReferences.ts @@ -12,6 +12,7 @@ import { workflowMolecule, workflowsListQueryStateAtom, } from "@agenta/entities/workflow" +import {canonicalizeMetricKey} from "@agenta/shared/metrics" import {createBatchFetcher} from "@agenta/shared/utils" import {atom} from "jotai" import {atomFamily} from "jotai-family" @@ -19,7 +20,6 @@ import {atomWithQuery} from "jotai-tanstack-query" import axios from "@/oss/lib/api/assets/axiosConfig" import {snakeToCamelCaseKeys} from "@/oss/lib/helpers/casing" -import {canonicalizeMetricKey} from "@/oss/lib/metricUtils" // ───────────────────────────────────────────────────────────────────────────── // Shared query-result shape for consumers that expect {data, isPending, ...} diff --git a/web/oss/src/components/References/atoms/metricBlueprint.ts b/web/oss/src/components/References/atoms/metricBlueprint.ts index d0e9c51853..cc3ed514f3 100644 --- a/web/oss/src/components/References/atoms/metricBlueprint.ts +++ b/web/oss/src/components/References/atoms/metricBlueprint.ts @@ -1,8 +1,7 @@ +import type {RunMetricDescriptor} from "@agenta/evaluations/state/runsTable" import {atom} from "jotai" import {atomFamily} from "jotai/utils" -import type {RunMetricDescriptor} from "@/oss/components/EvaluationRunsTablePOC/types/runMetrics" - export interface EvaluatorMetricGroupBlueprint { id: string label: string diff --git a/web/oss/src/components/References/cells/ApplicationCells.tsx b/web/oss/src/components/References/cells/ApplicationCells.tsx index 76cc24f1ef..bd8c999ccc 100644 --- a/web/oss/src/components/References/cells/ApplicationCells.tsx +++ b/web/oss/src/components/References/cells/ApplicationCells.tsx @@ -1,17 +1,17 @@ import {useMemo} from "react" import {workflowMolecule} from "@agenta/entities/workflow" -import {SkeletonLine} from "@agenta/ui/table" -import {getDefaultStore, useAtomValue} from "jotai" - +import type {EvaluationRunTableRow} from "@agenta/evaluations/state/runsTable" +import type {ReferenceColumnDescriptor} from "@agenta/evaluations/state/runsTable" import { useRunRowDetails, useRunRowReferences, useRunRowSummary, -} from "@/oss/components/EvaluationRunsTablePOC/context/RunRowDataContext" -import type {EvaluationRunTableRow} from "@/oss/components/EvaluationRunsTablePOC/types" -import type {ReferenceColumnDescriptor} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" -import {getSlotByRoleOrdinal} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" +} from "@agenta/evaluations/state/runsTable" +import {getSlotByRoleOrdinal} from "@agenta/evaluations/state/runsTable" +import {SkeletonLine} from "@agenta/ui/table" +import {getDefaultStore, useAtomValue} from "jotai" + import {extractPrimaryInvocation} from "@/oss/components/pages/evaluations/utils" import {getUniquePartOfId, isUuid} from "@/oss/lib/helpers/utils" diff --git a/web/oss/src/components/References/cells/CreatedByCells.tsx b/web/oss/src/components/References/cells/CreatedByCells.tsx index c8c33b746f..370e4ee51c 100644 --- a/web/oss/src/components/References/cells/CreatedByCells.tsx +++ b/web/oss/src/components/References/cells/CreatedByCells.tsx @@ -1,15 +1,11 @@ import {memo} from "react" import {UserAuthorLabel} from "@agenta/entities/shared/user" +import {useRunRowDetails, useRunRowSummary} from "@agenta/evaluations/state/runsTable" +import type {EvaluationRunTableRow} from "@agenta/evaluations/state/runsTable" import {SkeletonLine} from "@agenta/ui/table" import {Typography} from "antd" -import { - useRunRowDetails, - useRunRowSummary, -} from "@/oss/components/EvaluationRunsTablePOC/context/RunRowDataContext" -import type {EvaluationRunTableRow} from "@/oss/components/EvaluationRunsTablePOC/types" - const CELL_CLASS = "flex h-full w-full min-w-0 flex-col justify-center gap-1 px-2 whitespace-nowrap overflow-hidden" diff --git a/web/oss/src/components/References/cells/EvaluatorCells.tsx b/web/oss/src/components/References/cells/EvaluatorCells.tsx index aed510d446..72ed84bb68 100644 --- a/web/oss/src/components/References/cells/EvaluatorCells.tsx +++ b/web/oss/src/components/References/cells/EvaluatorCells.tsx @@ -1,16 +1,12 @@ import {useMemo} from "react" +import {humanizeEvaluatorName} from "@agenta/evaluations/core" +import type {EvaluationRunTableRow} from "@agenta/evaluations/state/runsTable" +import type {ReferenceColumnDescriptor} from "@agenta/evaluations/state/runsTable" +import {useRunRowReferences, useRunRowSummary} from "@agenta/evaluations/state/runsTable" +import {getSlotByRoleOrdinal} from "@agenta/evaluations/state/runsTable" import {SkeletonLine} from "@agenta/ui/table" -import { - useRunRowReferences, - useRunRowSummary, -} from "@/oss/components/EvaluationRunsTablePOC/context/RunRowDataContext" -import type {EvaluationRunTableRow} from "@/oss/components/EvaluationRunsTablePOC/types" -import type {ReferenceColumnDescriptor} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" -import {getSlotByRoleOrdinal} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" -import {humanizeEvaluatorName} from "@/oss/lib/evaluations/utils/metrics" - import useEvaluatorReference from "../hooks/useEvaluatorReference" const CELL_CLASS = diff --git a/web/oss/src/components/References/cells/QueryCells.tsx b/web/oss/src/components/References/cells/QueryCells.tsx index f88ca13a6c..e2d9c7829b 100644 --- a/web/oss/src/components/References/cells/QueryCells.tsx +++ b/web/oss/src/components/References/cells/QueryCells.tsx @@ -1,14 +1,10 @@ +import type {EvaluationRunTableRow} from "@agenta/evaluations/state/runsTable" +import type {ReferenceColumnDescriptor} from "@agenta/evaluations/state/runsTable" +import {formatSamplingRate, formatWindowRange} from "@agenta/evaluations-ui" import {CopyTooltip as TooltipWithCopyAction} from "@agenta/ui/copy-tooltip" import {SkeletonLine} from "@agenta/ui/table" import {Typography} from "antd" -import { - formatSamplingRate, - formatWindowRange, -} from "@/oss/components/EvalRunDetails/components/views/ConfigurationView/utils" -import type {EvaluationRunTableRow} from "@/oss/components/EvaluationRunsTablePOC/types" -import type {ReferenceColumnDescriptor} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" - import FiltersPreview from "../../pages/evaluations/onlineEvaluation/components/FiltersPreview" import usePreviewQueryRevision from "../hooks/usePreviewQueryRevision" diff --git a/web/oss/src/components/References/cells/TestsetCells.tsx b/web/oss/src/components/References/cells/TestsetCells.tsx index b06f1d84d4..044743f5d5 100644 --- a/web/oss/src/components/References/cells/TestsetCells.tsx +++ b/web/oss/src/components/References/cells/TestsetCells.tsx @@ -1,18 +1,15 @@ import {useMemo} from "react" import {testsetMolecule} from "@agenta/entities/testset" +import type {EvaluationRunTableRow} from "@agenta/evaluations/state/runsTable" +import type {ReferenceColumnDescriptor} from "@agenta/evaluations/state/runsTable" +import {useRunRowReferences, useRunRowSummary} from "@agenta/evaluations/state/runsTable" +import {getSlotByRoleOrdinal} from "@agenta/evaluations/state/runsTable" import {SkeletonLine} from "@agenta/ui/table" import {Tag} from "antd" import {getDefaultStore} from "jotai" import {useAtomValue} from "jotai" -import { - useRunRowReferences, - useRunRowSummary, -} from "@/oss/components/EvaluationRunsTablePOC/context/RunRowDataContext" -import type {EvaluationRunTableRow} from "@/oss/components/EvaluationRunsTablePOC/types" -import type {ReferenceColumnDescriptor} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" -import {getSlotByRoleOrdinal} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" import {revision} from "@/oss/state/entities/testset" // Entity molecule atoms must be read from the default store because they depend on diff --git a/web/oss/src/components/References/cells/VariantCells.tsx b/web/oss/src/components/References/cells/VariantCells.tsx index f4a3d55a7e..1141da750f 100644 --- a/web/oss/src/components/References/cells/VariantCells.tsx +++ b/web/oss/src/components/References/cells/VariantCells.tsx @@ -1,16 +1,13 @@ import {useMemo} from "react" import {VariantDetailsWithStatus} from "@agenta/entity-ui/variant" +import type {EvaluationRunTableRow} from "@agenta/evaluations/state/runsTable" +import type {ReferenceColumnDescriptor} from "@agenta/evaluations/state/runsTable" +import {useRunRowDetails, useRunRowReferences} from "@agenta/evaluations/state/runsTable" +import {getSlotByRoleOrdinal} from "@agenta/evaluations/state/runsTable" import {SkeletonLine} from "@agenta/ui/table" import {Typography} from "antd" -import { - useRunRowDetails, - useRunRowReferences, -} from "@/oss/components/EvaluationRunsTablePOC/context/RunRowDataContext" -import type {EvaluationRunTableRow} from "@/oss/components/EvaluationRunsTablePOC/types" -import type {ReferenceColumnDescriptor} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" -import {getSlotByRoleOrdinal} from "@/oss/components/EvaluationRunsTablePOC/utils/referenceSchema" import {extractPrimaryInvocation} from "@/oss/components/pages/evaluations/utils" import {getUniquePartOfId, isUuid} from "@/oss/lib/helpers/utils" diff --git a/web/oss/src/components/References/hooks/usePreviewQueryRevision.ts b/web/oss/src/components/References/hooks/usePreviewQueryRevision.ts index 563297ca1a..6bb9120483 100644 --- a/web/oss/src/components/References/hooks/usePreviewQueryRevision.ts +++ b/web/oss/src/components/References/hooks/usePreviewQueryRevision.ts @@ -1,11 +1,10 @@ import {useMemo} from "react" -import {LOW_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" - import { evaluationQueryRevisionAtomFamily, type EvaluationQueryConfigurationResult, -} from "@/oss/components/EvalRunDetails/atoms/query" +} from "@agenta/evaluations/state/evalRun" +import {LOW_PRIORITY, useAtomValueWithSchedule} from "jotai-scheduler" export const usePreviewQueryRevision = ( {runId}: {runId: string | null | undefined}, diff --git a/web/oss/src/components/References/index.ts b/web/oss/src/components/References/index.ts index 8300eeec50..d2e6c78d5c 100644 --- a/web/oss/src/components/References/index.ts +++ b/web/oss/src/components/References/index.ts @@ -12,7 +12,6 @@ export { VariantRevisionLabel, } from "./ReferenceLabels" export {VariantReferenceChip, TestsetReferenceChip, TestsetChipList} from "./ReferenceChips" -export * from "./referenceColors" // Re-export types and atoms for advanced usage export type { diff --git a/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx b/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx index 94e73a6274..fc8da233c0 100644 --- a/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx +++ b/web/oss/src/components/SharedDrawers/AddToTestsetDrawer/components/PreviewSection.tsx @@ -1,8 +1,8 @@ import {useMemo} from "react" +import {useRowHeight} from "@agenta/ui/table" import {Typography} from "antd" -import {useRowHeight} from "@/oss/components/InfiniteVirtualTable" import {TestcasesTableShell} from "@/oss/components/TestcasesTableNew/components/TestcasesTableShell" import {useTestcasesTable} from "@/oss/components/TestcasesTableNew/hooks/useTestcasesTable" import { diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/Annotate/assets/AnnotateCollapseContent/index.tsx b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/Annotate/assets/AnnotateCollapseContent/index.tsx index ef3a232df6..54eb00fa4a 100644 --- a/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/Annotate/assets/AnnotateCollapseContent/index.tsx +++ b/web/oss/src/components/SharedDrawers/AnnotateDrawer/assets/Annotate/assets/AnnotateCollapseContent/index.tsx @@ -1,6 +1,6 @@ import {memo} from "react" -import {AnnotationFieldRenderer} from "@/oss/components/EvalRunDetails/components/views/SingleScenarioViewerPOC/ScenarioAnnotationPanel/AnnotationInputs" +import {AnnotationFieldRenderer} from "@agenta/evaluations-ui" import {AnnotateCollapseContentProps} from "../types" diff --git a/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx b/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx index 29ee62ea50..073cbcdeee 100644 --- a/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx +++ b/web/oss/src/components/TestcasesTableNew/components/TestcaseHeader.tsx @@ -1,12 +1,12 @@ import {useEffect, useMemo, useState, type CSSProperties} from "react" +import {TableDescription} from "@agenta/ui/table" import {DownOutlined, MoreOutlined} from "@ant-design/icons" import {Export, Link, PencilSimple, Trash} from "@phosphor-icons/react" import {Button, Dropdown, Popover, Space, Typography} from "antd" import {useSetAtom} from "jotai" import {useRouter} from "next/router" -import {TableDescription} from "@/oss/components/InfiniteVirtualTable" import {UserReference} from "@/oss/components/References/UserReference" import type {ExportFileType} from "@/oss/services/testsets/api" import {enableRevisionsListQueryAtom} from "@/oss/state/entities/testset" diff --git a/web/oss/src/components/TestcasesTableNew/index.tsx b/web/oss/src/components/TestcasesTableNew/index.tsx index 9a803f0a93..b2f342bfab 100644 --- a/web/oss/src/components/TestcasesTableNew/index.tsx +++ b/web/oss/src/components/TestcasesTableNew/index.tsx @@ -1,10 +1,10 @@ import {useEffect, useMemo, useState} from "react" +import {useRowHeight} from "@agenta/ui/table" import {useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {useRouter} from "next/router" -import {useRowHeight} from "@/oss/components/InfiniteVirtualTable" import TestcaseEditDrawer from "@/oss/components/SharedDrawers/TestcaseDrawer" import useBlockNavigation from "@/oss/hooks/useBlockNavigation" import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" diff --git a/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts b/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts index d30a4f6d25..768aa659c8 100644 --- a/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts +++ b/web/oss/src/components/TestcasesTableNew/state/rowHeight.ts @@ -2,7 +2,7 @@ import { createRowHeightAtom, DEFAULT_ROW_HEIGHT_CONFIG, type RowHeightConfig, -} from "@/oss/components/InfiniteVirtualTable" +} from "@agenta/ui/table" /** * Testcase table row height configuration diff --git a/web/oss/src/components/TestsetsTable/TestsetsTable.tsx b/web/oss/src/components/TestsetsTable/TestsetsTable.tsx index 8556d47fe8..2ee1298bf1 100644 --- a/web/oss/src/components/TestsetsTable/TestsetsTable.tsx +++ b/web/oss/src/components/TestsetsTable/TestsetsTable.tsx @@ -2,6 +2,12 @@ import {useCallback, useEffect, useMemo, useState} from "react" import {testsetMolecule} from "@agenta/entities/testset" import {message} from "@agenta/ui/app-message" +import { + InfiniteVirtualTableFeatureShell, + useTableManager, + useTableActions, + type InfiniteDatasetStore, +} from "@agenta/ui/table" import {PlusOutlined} from "@ant-design/icons" import {ArchiveIcon, CaretDown, DownloadSimple} from "@phosphor-icons/react" import {Button, Dropdown, Space} from "antd" @@ -10,11 +16,6 @@ import {useAtom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" import {useRouter} from "next/router" -import { - InfiniteVirtualTableFeatureShell, - useTableManager, - useTableActions, -} from "@/oss/components/InfiniteVirtualTable" import TestsetsHeaderFilters from "@/oss/components/TestsetsTable/components/TestsetsHeaderFilters" import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" import useURL from "@/oss/hooks/useURL" @@ -368,7 +369,11 @@ const TestsetsTable = ({ // Table manager - consolidates pagination, selection, row handlers, export, delete buttons const table = useTableManager({ - datasetStore: tableState.paginatedStore.store, + datasetStore: tableState.paginatedStore.store as unknown as InfiniteDatasetStore< + TestsetTableRow, + unknown, + unknown + >, scopeId: isArchivedView ? "archived-testsets-page" : scopeId, pageSize: 50, rowHeight: 48, diff --git a/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx b/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx index 2c4556c59b..2df7139b55 100644 --- a/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx +++ b/web/oss/src/components/TestsetsTable/assets/createTestsetsColumns.tsx @@ -1,4 +1,5 @@ import {UserAuthorLabel} from "@agenta/entities/shared/user" +import {createStandardColumns} from "@agenta/ui/table" import {LoadingOutlined, MinusCircleOutlined, PlusCircleOutlined} from "@ant-design/icons" import { ArrowCounterClockwise, @@ -12,7 +13,6 @@ import { import {Tag} from "antd" import type {ColumnsType} from "antd/es/table" -import {createStandardColumns} from "@/oss/components/InfiniteVirtualTable" import CommitMessageCell from "@/oss/components/TestsetsTable/components/CommitMessageCell" import type {ExportFileType} from "@/oss/services/testsets/api" import type {TestsetTableMode, TestsetTableRow} from "@/oss/state/entities/testset" @@ -64,8 +64,8 @@ export function createTestsetsColumns( columnVisibilityLocked: true, render: (_value, record) => { const isRevision = Boolean((record as any).__isRevision) - const isExpanded = expandState.expandedRowKeys.includes(record.key) - const isLoading = expandState.loadingRows.has(record.key) + const isExpanded = expandState.expandedRowKeys.includes(String(record.key)) + const isLoading = expandState.loadingRows.has(String(record.key)) const isSkeleton = record.__isSkeleton if (isRevision) { diff --git a/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts b/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts index 04d98140d4..e5089358a0 100644 --- a/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts +++ b/web/oss/src/components/TestsetsTable/atoms/fetchTestsets.ts @@ -1,4 +1,5 @@ -import type {WindowingState} from "@/oss/components/InfiniteVirtualTable/types" +import type {WindowingState} from "@agenta/ui/table" + import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" diff --git a/web/oss/src/components/TestsetsTable/components/TestsetsFiltersContent.tsx b/web/oss/src/components/TestsetsTable/components/TestsetsFiltersContent.tsx index 1c1b782f85..afd8e7032b 100644 --- a/web/oss/src/components/TestsetsTable/components/TestsetsFiltersContent.tsx +++ b/web/oss/src/components/TestsetsTable/components/TestsetsFiltersContent.tsx @@ -3,7 +3,7 @@ import {useCallback, useEffect, useMemo, useState} from "react" import {Button, Divider, Typography} from "antd" import {useAtom} from "jotai" -import QuickDateRangePicker from "@/oss/components/EvaluationRunsTablePOC/components/filters/QuickDateRangePicker" +import QuickDateRangePicker from "@/oss/components/Filters/QuickDateRangePicker" import { getTestsetTableState, type TestsetDateRange, diff --git a/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx b/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx index 75e68e4e1b..e956d94b43 100644 --- a/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx +++ b/web/oss/src/components/TestsetsTable/components/TestsetsHeaderFilters.tsx @@ -1,9 +1,9 @@ import {useCallback, useState} from "react" +import {FiltersPopoverTrigger} from "@agenta/ui/table" import {Input} from "antd" import {useAtom} from "jotai" -import {FiltersPopoverTrigger} from "@/oss/components/InfiniteVirtualTable" import {getTestsetTableState, type TestsetTableMode} from "@/oss/state/entities/testset" import TestsetsFiltersContent from "./TestsetsFiltersContent" diff --git a/web/oss/src/components/EvalRunDetails/EvalResultsOnboarding.tsx b/web/oss/src/components/pages/evaluations/EvalResultsOnboarding.tsx similarity index 100% rename from web/oss/src/components/EvalRunDetails/EvalResultsOnboarding.tsx rename to web/oss/src/components/pages/evaluations/EvalResultsOnboarding.tsx diff --git a/web/oss/src/components/EvalRunDetails/test.tsx b/web/oss/src/components/pages/evaluations/EvalRunDetailsTestPage.tsx similarity index 55% rename from web/oss/src/components/EvalRunDetails/test.tsx rename to web/oss/src/components/pages/evaluations/EvalRunDetailsTestPage.tsx index 9c71b4e627..6c74f58229 100644 --- a/web/oss/src/components/EvalRunDetails/test.tsx +++ b/web/oss/src/components/pages/evaluations/EvalRunDetailsTestPage.tsx @@ -1,9 +1,13 @@ import {useMemo} from "react" +import { + EvalRunDetailsPage as EvalRunPreviewPage, + EvalRunFocusDrawerMount, +} from "@agenta/evaluations-ui" import {useRouter} from "next/router" -import EvalRunPreviewPage from "./components/Page" import EvalResultsOnboarding from "./EvalResultsOnboarding" +import EvalRunDetailsViewHost from "./EvalRunDetailsViewHost" type EvalRunKind = "auto" | "human" | "online" | "custom" @@ -31,14 +35,20 @@ const EvalRunTestPage = ({type = "auto"}: {type?: EvalRunKind}) => { } return ( -
- - -
+ +
+ + +
+ {/* Scenario focus drawer — opened by the run-details scenario table via the + focusScenarioId URL param. Mounted here (inside the host) rather than globally + in AppGlobalWrappers, so eval-view machinery no longer loads on every page. */} + +
) } diff --git a/web/oss/src/components/pages/evaluations/EvalRunDetailsViewHost.tsx b/web/oss/src/components/pages/evaluations/EvalRunDetailsViewHost.tsx new file mode 100644 index 0000000000..8431fbfa72 --- /dev/null +++ b/web/oss/src/components/pages/evaluations/EvalRunDetailsViewHost.tsx @@ -0,0 +1,204 @@ +/** + * OSS host boundary for the relocated eval run-details view (`@agenta/evaluations-ui` + * `EvalRunDetailsPage` / `EvalRunFocusDrawerMount`, WP-4h-5). + * + * The run-details view was moved into `@agenta/evaluations-ui` but legitimately depends on + * OSS-app-owned components (reference cells/labels, the generic + annotate drawers, the + * shared trace-result viewer, the prompt drill-in provider, the editor), OSS hooks + * (routing/breadcrumbs/permissions/evaluator details), OSS app-state atoms (workspace + * members, testcase query, reference resolvers, navigation request), and a few OSS pure + * functions (annotation transforms + services, date formatter, the evaluator-category + * label map). Rather than relocate those, this boundary supplies them through the three + * seam channels (§12.1c): + * + * 1. atoms → `registerEvalRunInjections` (`@agenta/evaluations/state`) + * 2. fns → `registerEvalViewFns` (`@agenta/evaluations-ui`) + * 3. slots → `EvalViewHostProvider` (`@agenta/evaluations-ui`) + * + * Wrap every OSS render site of the run-details view in ``: the six + * route pages (oss+ee × {results, single_model_test} × {project, app}) AND the global + * `AppGlobalWrappers` mount of `EvalRunFocusDrawerMount`. + */ + +import {memo, useEffect, useMemo, type ReactNode} from "react" + +import {registerEvalRunInjections, type InjectedReferenceResolver} from "@agenta/evaluations/state" +import {clearMetricSelectionCache} from "@agenta/evaluations/state/runsTable" +import { + EvalViewHostProvider, + invalidateEvaluationRunsTableAtom, + registerEvalViewFns, + registerRunViewInjections, + type EvalViewHost, + type InjectedNavigationCommand, +} from "@agenta/evaluations-ui" +import {type Atom, useAtomValue, useSetAtom} from "jotai" +import dynamic from "next/dynamic" + +import CustomTreeComponent from "@/oss/components/CustomUIs/CustomTreeComponent" +import {OSSdrillInUIProvider} from "@/oss/components/DrillInView/OSSdrillInUIProvider" +import SimpleSharedEditor from "@/oss/components/EditorViews/SimpleSharedEditor" +import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" +import GenericDrawer from "@/oss/components/GenericDrawer" +import EvaluatorDetailsPreview from "@/oss/components/pages/evaluations/onlineEvaluation/components/EvaluatorDetailsPreview" +import FiltersPreview from "@/oss/components/pages/evaluations/onlineEvaluation/components/FiltersPreview" +import {EVALUATOR_CATEGORY_LABEL_MAP} from "@/oss/components/pages/evaluations/onlineEvaluation/constants" +import {useEvaluatorDetails} from "@/oss/components/pages/evaluations/onlineEvaluation/hooks/useEvaluatorDetails" +import {useEvaluatorTypeFromConfigs} from "@/oss/components/pages/evaluations/onlineEvaluation/hooks/useEvaluatorTypeFromConfigs" +import {useEvaluatorTypeMeta} from "@/oss/components/pages/evaluations/onlineEvaluation/hooks/useEvaluatorTypeMeta" +import EmptyComponent from "@/oss/components/Placeholders/EmptyComponent" +import { + ApplicationReferenceLabel, + QueryReferenceLabel, + TestsetTag, + TestsetTagList, + TestsetChipList, + VariantReferenceChip, + VariantReferenceLabel, + VariantReferenceText, + VariantRevisionLabel, +} from "@/oss/components/References" +import { + appReferenceAtomFamily, + previewTestsetReferenceAtomFamily, + variantReferenceAtomFamily, +} from "@/oss/components/References/atoms/entityReferences" +import useEvaluatorReference from "@/oss/components/References/hooks/useEvaluatorReference" +import {EvaluatorReferenceLabel} from "@/oss/components/References/ReferenceLabels" +import ReferenceTag, {CopyIconButton} from "@/oss/components/References/ReferenceTag" +import { + generateAnnotationPayloadData, + generateNewAnnotationPayloadData, + getInitialMetricsFromAnnotations, + transformMetadata, +} from "@/oss/components/SharedDrawers/AnnotateDrawer/assets/transforms" +import SharedGenerationResultUtils from "@/oss/components/SharedGenerationResultUtils" +import {useAppId} from "@/oss/hooks/useAppId" +import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" +import {useQueryParam} from "@/oss/hooks/useQuery" +import useURL from "@/oss/hooks/useURL" +import {formatDate24} from "@/oss/lib/helpers/dateTimeHelper" +import {transformApiData} from "@/oss/lib/hooks/useAnnotations/assets/transformer" +import {useBreadcrumbsEffect} from "@/oss/lib/hooks/useBreadcrumbs" +import {createAnnotation, updateAnnotation} from "@/oss/services/annotations/api" +import {navigationRequestAtom} from "@/oss/state/appState" +import {testcaseQueryAtomFamily} from "@/oss/state/entities/testcase" +import {workspaceMembersAtom} from "@/oss/state/workspace/atoms/selectors" + +// Heavy: pull the EntityPicker / annotate stack only when a trigger opens them. +const EditEvaluationDrawer = dynamic(() => import("@/oss/components/EditEvaluationDrawer"), { + ssr: false, +}) +const Annotate = dynamic( + () => import("@/oss/components/SharedDrawers/AnnotateDrawer/assets/Annotate"), + { + ssr: false, + }, +) + +/** The three entity-reference resolver families, bundled to match the injected shape. */ +const referenceResolver: InjectedReferenceResolver = { + appReferenceAtomFamily, + variantReferenceAtomFamily, + previewTestsetReferenceAtomFamily, +} + +// fn-channel registration is global + stable; do it once at module load. The annotation +// transform/service seams own heavily-`any` OSS payload shapes (see fnRegistry §11.4), so +// the structurally-compatible impls are adapted at the boundary. +registerEvalViewFns({ + formatDate24, + + createAnnotation: (payload: any) => createAnnotation(payload), + + updateAnnotation: (payload: any) => + updateAnnotation(payload as Parameters[0]), + + transformMetadata: (args: {data: any}) => transformMetadata(args), + + generateAnnotationPayloadData: (args: any) => generateAnnotationPayloadData(args), + + generateNewAnnotationPayloadData: (args: any) => generateNewAnnotationPayloadData(args), + + getInitialMetricsFromAnnotations: (args: any) => getInitialMetricsFromAnnotations(args), + SimpleSharedEditor, + evaluatorCategoryLabelMap: EVALUATOR_CATEGORY_LABEL_MAP, +}) + +/** Registers the run-details atom seams from their real OSS sources (reactive where needed). */ +const useRegisterEvalRunDetailsInjections = () => { + const register = useSetAtom(registerEvalRunInjections) + const registerView = useSetAtom(registerRunViewInjections) + const workspaceMembers = useAtomValue(workspaceMembersAtom) + const invalidateRunsTable = useSetAtom(invalidateEvaluationRunsTableAtom) + + useEffect(() => { + // shared eval-run seams (headless @agenta/evaluations) + register({ + workspaceMembers, + testcaseQueryFamily: testcaseQueryAtomFamily, + referenceResolver, + runInvalidate: () => invalidateRunsTable(), + clearMetricSelection: clearMetricSelectionCache, + annotationTransform: transformApiData, + }) + // run-view seams (relocated to @agenta/evaluations-ui) + registerView({ + // The OSS navigation atom, injected by reference; the focus-drawer URL sync reads + // it imperatively via `store.get`. + navigationRequest: + navigationRequestAtom as unknown as Atom, + }) + }, [register, registerView, workspaceMembers, invalidateRunsTable]) +} + +/** Wraps the relocated run-details view, supplying every OSS seam it depends on. */ +const EvalRunDetailsViewHost = ({children}: {children: ReactNode}) => { + useRegisterEvalRunDetailsInjections() + + const host = useMemo( + () => ({ + components: { + EnhancedDrawer, + GenericDrawer, + CustomTreeComponent, + EmptyComponent, + ReferenceTag, + CopyIconButton, + SharedGenerationResultUtils, + FiltersPreview, + EvaluatorDetailsPreview, + EvaluatorReferenceLabel, + OSSdrillInUIProvider, + TestsetChipList, + VariantReferenceChip, + Annotate, + EditEvaluationDrawer, + // Generic reference labels wrapped by the eval-scoped reference labels. + GenericApplicationReferenceLabel: ApplicationReferenceLabel, + GenericQueryReferenceLabel: QueryReferenceLabel, + GenericTestsetTag: TestsetTag, + GenericTestsetTagList: TestsetTagList, + GenericVariantReferenceLabel: VariantReferenceLabel, + GenericVariantReferenceText: VariantReferenceText, + GenericVariantRevisionLabel: VariantRevisionLabel, + }, + hooks: { + useProjectPermissions, + useAppId, + useURL, + useQueryParam, + useBreadcrumbsEffect, + useEvaluatorReference, + useEvaluatorDetails, + useEvaluatorTypeMeta, + useEvaluatorTypeFromConfigs, + }, + }), + [], + ) + + return {children} +} + +export default memo(EvalRunDetailsViewHost) diff --git a/web/oss/src/components/pages/evaluations/EvalRunsViewHost.tsx b/web/oss/src/components/pages/evaluations/EvalRunsViewHost.tsx new file mode 100644 index 0000000000..8de2ae6e1d --- /dev/null +++ b/web/oss/src/components/pages/evaluations/EvalRunsViewHost.tsx @@ -0,0 +1,205 @@ +/** + * OSS host boundary for the relocated eval run-list view (`@agenta/evaluations-ui` + * `EvaluationRunsTable` / `LatestEvaluationRunsTable`, WP-4h-4). + * + * The run-list view was moved into `@agenta/evaluations-ui` but legitimately depends on + * OSS-app-owned components (reference cells, empty states, modals/drawers, the date-range + * picker, the online-eval filters preview), OSS hooks (routing/permissions), OSS app-state + * atoms (apps/url/route/queries/workflow/onboarding), and a few OSS pure functions + * (URL builders, payload normalizers). Rather than relocate those, this boundary supplies + * them through the three seam channels (§12.1c): + * + * 1. atoms → `registerEvalRunInjections` (`@agenta/evaluations/state`) + * 2. fns → `registerEvalViewFns` (`@agenta/evaluations-ui`) + * 3. slots → `EvalViewHostProvider` (`@agenta/evaluations-ui`) + * + * Wrap every OSS render site of the run-list view in ``. + */ + +import {memo, useEffect, useMemo, type ReactNode} from "react" + +import {registerEvalRunInjections, type InjectedReferenceResolver} from "@agenta/evaluations/state" +import {clearMetricSelectionCache} from "@agenta/evaluations/state/runsTable" +import { + EvalViewHostProvider, + invalidateEvaluationRunsTableAtom, + registerEvalViewFns, + registerRunViewInjections, + type EvalViewHost, + type EvalViewUrlState, + type InjectedUrlState, +} from "@agenta/evaluations-ui" +import {useAtomValue, useSetAtom} from "jotai" + +import DeleteEvaluationModal from "@/oss/components/DeleteEvaluationModal/DeleteEvaluationModal" +import EditEvaluationDrawer from "@/oss/components/EditEvaluationDrawer" +import QuickDateRangePicker from "@/oss/components/Filters/QuickDateRangePicker" +import EmptyStateAllEvaluations from "@/oss/components/pages/evaluations/allEvaluations/EmptyStateAllEvaluations" +import EmptyStateEvaluation from "@/oss/components/pages/evaluations/autoEvaluation/EmptyStateEvaluation" +import EmptyStateHumanEvaluation from "@/oss/components/pages/evaluations/humanEvaluation/EmptyStateHumanEvaluation" +import NewEvaluationModal from "@/oss/components/pages/evaluations/NewEvaluation" +import {fromFilteringPayload} from "@/oss/components/pages/evaluations/onlineEvaluation/assets/helpers" +import FiltersPreview from "@/oss/components/pages/evaluations/onlineEvaluation/components/FiltersPreview" +import EmptyStateOnlineEvaluation from "@/oss/components/pages/evaluations/onlineEvaluation/EmptyStateOnlineEvaluation" +import OnlineEvaluationDrawer from "@/oss/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer" +import EmptyStateSdkEvaluation from "@/oss/components/pages/evaluations/sdkEvaluation/EmptyStateSdkEvaluation" +import SetupEvaluationModal from "@/oss/components/pages/evaluations/SetupEvaluationModal" +import { + extractPrimaryInvocation, + buildAppScopedUrl, + buildEvaluationNavigationUrl, +} from "@/oss/components/pages/evaluations/utils" +import { + appReferenceAtomFamily, + variantReferenceAtomFamily, + previewTestsetReferenceAtomFamily, + evaluatorReferenceAtomFamily, +} from "@/oss/components/References/atoms/entityReferences" +import {getEvaluatorMetricBlueprintAtom} from "@/oss/components/References/atoms/metricBlueprint" +import {resolvedMetricLabelsAtomFamily} from "@/oss/components/References/atoms/resolvedMetricLabels" +import {PreviewAppCell} from "@/oss/components/References/cells/ApplicationCells" +import {PreviewCreatedByCell} from "@/oss/components/References/cells/CreatedByCells" +import {PreviewEvaluatorCell} from "@/oss/components/References/cells/EvaluatorCells" +import {PreviewQueryCell} from "@/oss/components/References/cells/QueryCells" +import {PreviewTestsetCell} from "@/oss/components/References/cells/TestsetCells" +import {PreviewVariantCell} from "@/oss/components/References/cells/VariantCells" +import useEvaluatorReference from "@/oss/components/References/hooks/useEvaluatorReference" +import {useProjectPermissions} from "@/oss/hooks/useProjectPermissions" +import {buildRevisionsQueryParam} from "@/oss/lib/helpers/url" +import { + onboardingWidgetActivationAtom, + recordWidgetEventAtom, + setOnboardingWidgetActivationAtom, +} from "@/oss/lib/onboarding" +import {startSimpleEvaluation, stopSimpleEvaluation} from "@/oss/services/onlineEvaluations/api" +import {appsQueryAtom, routerAppIdAtom} from "@/oss/state/app" +import {appIdentifiersAtom, routeLayerAtom, useQueryParamState} from "@/oss/state/appState" +import {queriesQueryAtomFamily} from "@/oss/state/queries" +import {urlAtom, waitForValidURL} from "@/oss/state/url" +import {currentWorkflowAtom} from "@/oss/state/workflow" +import { + workspaceMemberByIdFamily, + workspaceMembersAtom, +} from "@/oss/state/workspace/atoms/selectors" + +/** Three entity-reference resolver families, bundled to match the injected shape. */ +const referenceResolver: InjectedReferenceResolver = { + appReferenceAtomFamily, + variantReferenceAtomFamily, + previewTestsetReferenceAtomFamily, +} + +// fn-channel registration is global + stable; do it once at module load. The seam types are +// intentionally looser than the OSS impls (it owns the concrete `URLState`/`EvaluationRow`/ +// `QueryFilteringPayload` shapes), so the structurally-compatible impls are adapted at the +// boundary. +registerEvalViewFns({ + waitForValidURL: async (options): Promise => + (await waitForValidURL(options)) as unknown as EvalViewUrlState, + buildAppScopedUrl, + buildEvaluationNavigationUrl, + buildRevisionsQueryParam, + extractPrimaryInvocation: (evaluation) => + extractPrimaryInvocation(evaluation as Parameters[0]), + fromFilteringPayload: (payload) => + fromFilteringPayload(payload as Parameters[0]), +}) + +/** Registers the run-list atom seams from their real OSS sources (reactive where needed). */ +const useRegisterEvalRunsViewInjections = () => { + const register = useSetAtom(registerEvalRunInjections) + const registerView = useSetAtom(registerRunViewInjections) + const workspaceMembers = useAtomValue(workspaceMembersAtom) + const apps = useAtomValue(appsQueryAtom) + const routerAppId = useAtomValue(routerAppIdAtom) + const url = useAtomValue(urlAtom) + const appIdentifiers = useAtomValue(appIdentifiersAtom) + const routeLayer = useAtomValue(routeLayerAtom) + const currentWorkflow = useAtomValue(currentWorkflowAtom) + const onboardingWidgetActivation = useAtomValue(onboardingWidgetActivationAtom) + const setOnboardingWidgetActivation = useSetAtom(setOnboardingWidgetActivationAtom) + const recordWidgetEvent = useSetAtom(recordWidgetEventAtom) + const invalidateRunsTable = useSetAtom(invalidateEvaluationRunsTableAtom) + + useEffect(() => { + // shared eval-run seams (headless @agenta/evaluations) + register({ + workspaceMembers, + referenceResolver, + clearMetricSelection: clearMetricSelectionCache, + runInvalidate: () => invalidateRunsTable(), + }) + // run-view seams (relocated to @agenta/evaluations-ui) + registerView({ + onlineEvaluationsApi: {startSimpleEvaluation, stopSimpleEvaluation}, + appsQuery: apps, + routerAppId, + url: url as unknown as InjectedUrlState, + appIdentifiers, + routeLayer, + currentWorkflow, + queriesQueryFamily: queriesQueryAtomFamily, + metricBlueprintFactory: getEvaluatorMetricBlueprintAtom, + resolvedMetricLabelsFamily: resolvedMetricLabelsAtomFamily, + evaluatorReferenceFamily: evaluatorReferenceAtomFamily, + workspaceMemberByIdFamily, + onboardingWidgetActivation, + setOnboardingWidgetActivation: (value) => setOnboardingWidgetActivation(value), + recordWidgetEvent: (eventId) => recordWidgetEvent(eventId), + }) + }, [ + register, + registerView, + workspaceMembers, + apps, + routerAppId, + url, + appIdentifiers, + routeLayer, + currentWorkflow, + onboardingWidgetActivation, + setOnboardingWidgetActivation, + recordWidgetEvent, + invalidateRunsTable, + ]) +} + +/** Wraps the relocated run-list view, supplying every OSS seam it depends on. */ +const EvalRunsViewHost = ({children}: {children: ReactNode}) => { + useRegisterEvalRunsViewInjections() + + const host = useMemo( + () => ({ + components: { + PreviewAppCell, + PreviewVariantCell, + PreviewTestsetCell, + PreviewQueryCell, + PreviewEvaluatorCell, + PreviewCreatedByCell, + QuickDateRangePicker, + FiltersPreview, + EmptyStateAllEvaluations, + EmptyStateEvaluation, + EmptyStateHumanEvaluation, + EmptyStateOnlineEvaluation, + EmptyStateSdkEvaluation, + DeleteEvaluationModal, + NewEvaluationModal, + OnlineEvaluationDrawer, + SetupEvaluationModal, + EditEvaluationDrawer, + }, + hooks: { + useProjectPermissions, + useQueryParamState, + useEvaluatorReference, + }, + }), + [], + ) + + return {children} +} + +export default memo(EvalRunsViewHost) diff --git a/web/oss/src/components/pages/evaluations/EvaluationsView.tsx b/web/oss/src/components/pages/evaluations/EvaluationsView.tsx index 5a22053d04..772f4ad942 100644 --- a/web/oss/src/components/pages/evaluations/EvaluationsView.tsx +++ b/web/oss/src/components/pages/evaluations/EvaluationsView.tsx @@ -9,6 +9,15 @@ import { type ReactNode, } from "react" +import { + ConcreteEvaluationRunKind, + type EvaluationRunKind, +} from "@agenta/evaluations/state/runsTable" +import { + EvaluationRunsTablePOC, + evaluationRunsTableContextSetterAtom, + evaluationRunsTypeFiltersAtom, +} from "@agenta/evaluations-ui" import {PageLayout} from "@agenta/ui" import {CloudServerOutlined} from "@ant-design/icons" import {ChartDonutIcon, CodeIcon, ListChecksIcon} from "@phosphor-icons/react" @@ -16,18 +25,11 @@ import type {TabsProps} from "antd" import {useAtomValue, useSetAtom} from "jotai" import {useRouter} from "next/router" -import { - EvaluationRunsTablePOC, - type EvaluationRunKind, -} from "@/oss/components/EvaluationRunsTablePOC" -import {evaluationRunsTableContextSetterAtom} from "@/oss/components/EvaluationRunsTablePOC/atoms/context" -import {evaluationRunsTypeFiltersAtom} from "@/oss/components/EvaluationRunsTablePOC/atoms/view" +import EvalRunsViewHost from "@/oss/components/pages/evaluations/EvalRunsViewHost" import {useBreadcrumbsEffect} from "@/oss/lib/hooks/useBreadcrumbs" import {useQueryParamState} from "@/oss/state/appState" import {projectIdAtom} from "@/oss/state/project" -import {ConcreteEvaluationRunKind} from "../../EvaluationRunsTablePOC/types" - type EvaluationScope = "app" | "project" type AppTabKey = EvaluationRunKind @@ -249,12 +251,14 @@ const EvaluationsView = ({scope = "app", appId}: EvaluationsViewProps) => { const tabItems = scope === "project" ? PROJECT_TAB_ITEMS : APP_TAB_ITEMS return ( - + + + ) } diff --git a/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalContent.tsx b/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalContent.tsx index bfb6bcdfe6..286d1e119d 100644 --- a/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalContent.tsx +++ b/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalContent.tsx @@ -2,6 +2,7 @@ import {type FC, memo, useCallback, useMemo} from "react" import {workflowMolecule} from "@agenta/entities/workflow" import {createEvaluatorFromTemplate} from "@agenta/entities/workflow" +import {openWorkflowRevisionDrawerAtom} from "@agenta/playground-ui/workflow-revision-drawer" import {message} from "@agenta/ui/app-message" import {CloseCircleOutlined} from "@ant-design/icons" import {Input, Tabs, Tag, Typography} from "antd" @@ -12,7 +13,6 @@ import dynamic from "next/dynamic" import {openHumanEvaluatorDrawerAtom} from "@/oss/components/Evaluators/Drawers/HumanEvaluatorDrawer/store" import useFocusInput from "@/oss/hooks/useFocusInput" import type {Evaluator} from "@/oss/lib/Types" -import {openEvaluatorDrawerAtom} from "@/oss/state/evaluator/evaluatorDrawerStore" import TabLabel from "../assets/TabLabel" import {NewEvaluationModalContentProps} from "../types" @@ -109,7 +109,7 @@ const NewEvaluationModalContent: FC = ({ const {inputRef} = useFocusInput({isOpen: props.isOpen || false}) const appSelectionComplete = Boolean(selectedAppId) - const openEvaluatorDrawer = useSetAtom(openEvaluatorDrawerAtom) + const openEvaluatorDrawer = useSetAtom(openWorkflowRevisionDrawerAtom) const openHumanDrawer = useSetAtom(openHumanEvaluatorDrawerAtom) // Handler for opening the human evaluator creation drawer (preview mode) @@ -134,7 +134,7 @@ const NewEvaluationModalContent: FC = ({ openEvaluatorDrawer({ entityId: localId, - mode: "create", + context: "evaluator-create", onEvaluatorCreated, }) onSelectTemplate?.(evaluator) diff --git a/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalInner.tsx b/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalInner.tsx index c756b78ee5..00e4ee10af 100644 --- a/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalInner.tsx +++ b/web/oss/src/components/pages/evaluations/NewEvaluation/Components/NewEvaluationModalInner.tsx @@ -17,6 +17,7 @@ import { invalidateWorkflowsListCache, invalidateEvaluatorsListCache, } from "@agenta/entities/workflow" +import {usePreviewEvaluations} from "@agenta/evaluations/hooks" import {message} from "@agenta/ui/app-message" import {useAtom, useAtomValue, useSetAtom} from "jotai" import dynamic from "next/dynamic" @@ -31,10 +32,10 @@ import {registryWorkflowIdOverrideAtom} from "@/oss/components/VariantsComponent import useURL from "@/oss/hooks/useURL" import {resolveEvaluatorKey} from "@/oss/lib/evaluators/utils" import {redirectIfNoLLMKeys} from "@/oss/lib/helpers/utils" -import usePreviewEvaluations from "@/oss/lib/hooks/usePreviewEvaluations" import {activeTourIdAtom, currentStepStateAtom} from "@/oss/lib/onboarding" import {createEvaluation} from "@/oss/services/evaluations/api" import {useAppsData} from "@/oss/state/app/hooks" +import {currentAppContextAtom} from "@/oss/state/app/selectors/app" import {appIdentifiersAtom} from "@/oss/state/appState" import {testsetsListQueryAtomFamily} from "@/oss/state/entities/testset" @@ -327,9 +328,11 @@ const NewEvaluationModalInner = ({ return workflowRevisions || [] }, [workflowRevisions, selectedAppId]) + const isCustomApp = useAtomValue(currentAppContextAtom)?.appType === "custom" const {createNewRun: createPreviewEvaluationRun} = usePreviewEvaluations({ appId: selectedAppId || appId, skip: false, + isCustomApp, }) const testsetsQuery = useAtomValue(testsetsListQueryAtomFamily(null)) const testsets = testsetsQuery.data?.testsets ?? [] @@ -592,7 +595,7 @@ const NewEvaluationModalInner = ({ const data = await createPreviewEvaluationRun(structuredClone(selectionData) as any) - const runId = data.run.runs[0].id + const runId = data.runId const scope = isAppScoped ? "app" : "project" const targetPath = buildEvaluationNavigationUrl({ scope, diff --git a/web/oss/src/components/pages/evaluations/allEvaluations/EmptyStateAllEvaluations/EmptyStateAllEvaluations.tsx b/web/oss/src/components/pages/evaluations/allEvaluations/EmptyStateAllEvaluations/EmptyStateAllEvaluations.tsx index f95e79eae3..ab14f226e5 100644 --- a/web/oss/src/components/pages/evaluations/allEvaluations/EmptyStateAllEvaluations/EmptyStateAllEvaluations.tsx +++ b/web/oss/src/components/pages/evaluations/allEvaluations/EmptyStateAllEvaluations/EmptyStateAllEvaluations.tsx @@ -1,6 +1,7 @@ +import {EvaluationRunsCreateButton} from "@agenta/evaluations-ui" + import EmptyState from "@/oss/components/EmptyState" import {EMPTY_STATE_VIDEOS} from "@/oss/components/EmptyState/videos" -import EvaluationRunsCreateButton from "@/oss/components/EvaluationRunsTablePOC/components/EvaluationRunsCreateButton" const EmptyStateAllEvaluations = () => { return ( diff --git a/web/oss/src/components/pages/evaluations/cellRenderers/cellRenderers.tsx b/web/oss/src/components/pages/evaluations/cellRenderers/cellRenderers.tsx index 65f2d7c731..e51270b9e6 100644 --- a/web/oss/src/components/pages/evaluations/cellRenderers/cellRenderers.tsx +++ b/web/oss/src/components/pages/evaluations/cellRenderers/cellRenderers.tsx @@ -1,5 +1,6 @@ import {memo, useCallback, useEffect, useState} from "react" +import {EvaluationStatus} from "@agenta/entities/evaluationRun" import {message} from "@agenta/ui/app-message" import { CopyOutlined, @@ -16,13 +17,7 @@ import {createUseStyles} from "react-jss" import {useDurationCounter} from "@/oss/hooks/useDurationCounter" import {getTypedValue} from "@/oss/lib/evaluations/legacy" -import { - EvaluationStatus, - EvaluatorConfig, - JSSTheme, - _Evaluation, - _EvaluationScenario, -} from "@/oss/lib/Types" +import {EvaluatorConfig, JSSTheme, _Evaluation, _EvaluationScenario} from "@/oss/lib/Types" dayjs.extend(relativeTime) dayjs.extend(duration) diff --git a/web/oss/src/lib/Types.ts b/web/oss/src/lib/Types.ts index 2489102eaa..dbc6884abe 100644 --- a/web/oss/src/lib/Types.ts +++ b/web/oss/src/lib/Types.ts @@ -1,23 +1,7 @@ +import {EvaluationStatus} from "@agenta/entities/evaluationRun" import type {GlobalToken} from "antd" import type {StaticImageData} from "next/image" -// Type utility to convert snake_case object properties to camelCase -export type SnakeToCamelCaseKeys = T extends readonly any[] - ? T extends [infer First, ...infer Rest] - ? [SnakeToCamelCaseKeys, ...SnakeToCamelCaseKeys] - : T extends (infer U)[] - ? SnakeToCamelCaseKeys[] - : T - : T extends object - ? { - [K in keyof T as SnakeToCamelCase]: SnakeToCamelCaseKeys - } - : T - -export type SnakeToCamelCase = S extends `${infer T}_${infer U}` - ? `${T}${Capitalize>}` - : S - export interface WorkspaceRole { role_description: string role_name: string @@ -300,23 +284,6 @@ export interface TypedValue { error: null | EvaluationError } -export enum EvaluationStatus { - INITIALIZED = "EVALUATION_INITIALIZED", - STARTED = "EVALUATION_STARTED", - FINISHED = "EVALUATION_FINISHED", - FINISHED_WITH_ERRORS = "EVALUATION_FINISHED_WITH_ERRORS", - ERROR = "EVALUATION_FAILED", - AGGREGATION_FAILED = "EVALUATION_AGGREGATION_FAILED", - RUNNING = "running", - SUCCESS = "success", - FAILURE = "failure", - FAILED = "failed", - ERRORS = "errors", - CANCELLED = "cancelled", - PENDING = "pending", - INCOMPLETE = "incomplete", -} - export enum EvaluationStatusType { STATUS = "status", ERROR = "error", diff --git a/web/oss/src/lib/evalRunner/types.ts b/web/oss/src/lib/evalRunner/types.ts deleted file mode 100644 index 8e632f84f5..0000000000 --- a/web/oss/src/lib/evalRunner/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type {IStepResponse} from "@/oss/lib/evaluations" -import {EvaluationStatus} from "@/oss/lib/Types" - -export interface RunEvalMessage { - type: "run-invocation" - jwt: string - appId: string - scenarioId: string - runId: string - apiUrl: string - requestBody: Record - projectId: string - endpoint: string - invocationKey?: string - invocationStepTarget?: IStepResponse -} - -export interface ResultMessage { - type: "result" - scenarioId: string - status: EvaluationStatus - result?: any - error?: string - invocationStepTarget?: IStepResponse - invocationKey?: string -} - -export interface JwtUpdateMessage { - type: "UPDATE_JWT" - jwt: string -} - -export interface ConfigMessage { - type: "config" - maxConcurrent: number -} - -export type WorkerMessage = RunEvalMessage | ConfigMessage | JwtUpdateMessage diff --git a/web/oss/src/lib/evaluations/index.ts b/web/oss/src/lib/evaluations/index.ts deleted file mode 100644 index 520e72b723..0000000000 --- a/web/oss/src/lib/evaluations/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -export { - buildRunIndex, - serializeRunIndex, - deserializeRunIndex, - type StepKind, - type ColumnDef, - type StepMeta, - type RunIndex, -} from "./buildRunIndex" - -export type { - StepResponse, - StepResponseStep, - IStepResponse, - TraceNode, - TraceData, - TraceTree, - InvocationParameters, - IInvocationStep, - IInputStep, - IAnnotationStep, - UseEvaluationRunScenarioStepsOptions, - UseEvaluationRunScenarioStepsResult, - UseEvaluationRunScenarioStepsConfig, - UseEvaluationRunScenarioStepsFetcherResult, -} from "./types" diff --git a/web/oss/src/lib/evaluations/types.ts b/web/oss/src/lib/evaluations/types.ts deleted file mode 100644 index 566b103e5b..0000000000 --- a/web/oss/src/lib/evaluations/types.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {SWRConfiguration, SWRResponse} from "swr" - -import type {AnnotationDto} from "@/oss/lib/hooks/useAnnotations/types" -import type {PreviewTestset, SnakeToCamelCaseKeys} from "@/oss/lib/Types" - -// --- Step Response Types (snake_case from API) --- -export interface StepResponse { - steps: StepResponseStep[] - count: number - next?: string -} - -export interface StepResponseStep { - id: string - run_id: string - scenario_id: string - step_key: string - repeat_idx?: number - timestamp?: string - interval?: number - status: string - trace_id?: string - testcase_id?: string - error?: Record - created_at?: string - created_by_id?: string - is_legacy?: boolean - inputs?: Record - ground_truth?: Record -} - -/** Step response in camelCase (derived from StepResponseStep) */ -export type IStepResponse = SnakeToCamelCaseKeys - -// --- Trace Types --- -export interface TraceNode { - trace_id: string - span_id: string - lifecycle: { - created_at: string - } - root: { - id: string - } - tree: { - id: string - } - node: { - id: string - name: string - type: string - } - parent?: { - id: string - } - time: { - start: string - end: string - } - status: { - code: string - } - data: Record - metrics: Record - refs: Record - otel: { - kind: string - attributes: Record - } - nodes?: Record -} - -export interface TraceData { - trees: TraceTree[] - version: string - count: number -} - -export interface TraceTree { - tree: { - id: string - } - nodes: TraceNode[] -} - -// --- Invocation Types --- -export type InvocationParameters = Record< - string, - { - requestBody: { - ag_config: { - prompt: { - messages: {role: string; content: string}[] - template_format: string - input_keys: string[] - llm_config: { - model: string - tools: any[] - } - } - } - inputs: Record - } - endpoint: string - } | null -> - -// --- Extended Step Types --- -export interface IInvocationStep extends IStepResponse { - trace?: TraceTree - invocationParameters?: InvocationParameters -} - -export interface IInputStep extends IStepResponse { - inputs?: Record - groundTruth?: Record - testcase?: PreviewTestset["data"]["testcases"][number] -} - -export interface IAnnotationStep extends IStepResponse { - annotation?: AnnotationDto -} - -// --- Hook-specific Types --- -export interface UseEvaluationRunScenarioStepsOptions { - limit?: number - next?: string - keys?: string[] - statuses?: string[] -} - -export interface UseEvaluationRunScenarioStepsResult { - isLoading: boolean - swrData: SWRResponse - mutate: () => Promise -} - -export interface UseEvaluationRunScenarioStepsConfig extends SWRConfiguration { - concurrency?: number -} - -export interface UseEvaluationRunScenarioStepsFetcherResult { - steps: IStepResponse[] - mappings?: any[] - annotationSteps: IAnnotationStep[] - invocationSteps: IInvocationStep[] - inputSteps: IInputStep[] - annotations?: AnnotationDto[] | null - inputStep?: IStepResponse - scenarioId?: string - trace?: TraceTree | TraceData | null - invocationParameters?: InvocationParameters -} diff --git a/web/oss/src/lib/hooks/useEvaluationRunMetrics/assets/utils.ts b/web/oss/src/lib/hooks/useEvaluationRunMetrics/assets/utils.ts deleted file mode 100644 index b990f89ad6..0000000000 --- a/web/oss/src/lib/hooks/useEvaluationRunMetrics/assets/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import axios from "@/oss/lib/api/assets/axiosConfig" - -import type {MetricResponse} from "../types" - -/** - * SWR fetcher for fetching metrics from the API. - * - * Given a URL, this function performs a GET request to the URL, extracts the - * `metrics` array, `count`, and `next` properties from the response, and - * returns them in an object. - * - * @param {string} url The URL to fetch - * @return {Promise<{metrics: MetricResponse[], count: number, next?: string}>} - */ -export const fetcher = (url: string) => - axios.get(url).then((res) => { - const raw = res.data - const metrics: MetricResponse[] = Array.isArray(raw.metrics) ? raw.metrics : [] - return { - metrics, - count: raw.count as number, - next: raw.next as string | undefined, - } - }) diff --git a/web/oss/src/lib/hooks/useEvaluationRunMetrics/index.ts b/web/oss/src/lib/hooks/useEvaluationRunMetrics/index.ts deleted file mode 100644 index 3f5f158ef0..0000000000 --- a/web/oss/src/lib/hooks/useEvaluationRunMetrics/index.ts +++ /dev/null @@ -1,112 +0,0 @@ -import {useMemo} from "react" - -import useSWR from "swr" - -import { - METRICS_ENDPOINT, - createScenarioMetrics, - updateMetric, - updateMetrics, - computeRunMetrics, -} from "@/oss/services/runMetrics/api" - -import {fetcher} from "./assets/utils" -import type { - MetricResponse, - Metric, - UseEvaluationRunMetricsOptions, - UseEvaluationRunMetricsResult, -} from "./types" - -/** - * Hook to fetch and create metrics for a specific evaluation run (and optionally scenario). - * - * @param runId The UUID of the evaluation run. If falsy, fetching is skipped. - * @param options Optional filters/pagination: { limit, next, scenarioIds, statuses }. - */ -const useEvaluationRunMetrics = ( - runIds: string | string[] | null | undefined, - scenarioId?: string | null, - options?: UseEvaluationRunMetricsOptions, -): UseEvaluationRunMetricsResult => { - // Build query parameters - const queryParams = new URLSearchParams() - - // Append one or many run_ids query params - if (runIds) { - if (Array.isArray(runIds) && runIds.length > 0) { - // Ensure deterministic ordering for SWR key stability - const sorted = [...runIds].sort() - sorted.forEach((id) => queryParams.append("run_ids", id)) - } else { - queryParams.append("run_ids", runIds) - } - } - if (options?.limit !== undefined) { - queryParams.append("limit", options.limit.toString()) - } - if (options?.next) { - queryParams.append("next", options.next) - } - if (scenarioId) { - queryParams.append("scenario_ids", scenarioId) - } else if (options?.scenarioIds) { - options.scenarioIds.forEach((sid) => queryParams.append("scenario_ids", sid)) - } - if (options?.statuses) { - options.statuses.forEach((st) => queryParams.append("status", st)) - } - - const swrKey = useMemo(() => { - const queryRunIds = queryParams.getAll("run_ids").filter((a) => a !== "undefined" && !!a) - const queryScenarioIds = queryParams - .getAll("scenario_ids") - .filter((a) => a !== "undefined" && !!a) - - return queryRunIds.length > 0 || queryScenarioIds.length > 0 - ? `${METRICS_ENDPOINT}?${queryParams.toString()}` - : null - }, [queryParams]) - - // SWR response typed to raw MetricResponse[] - const swrData = useSWR<{ - metrics: MetricResponse[] - count: number - next?: string - }>(swrKey, fetcher) - - // Convert raw MetricResponse[] to camelCase Metric[] - const rawMetrics = swrData.data?.metrics - const camelMetrics: Metric[] | undefined = rawMetrics - ? rawMetrics.map((item) => item) - : undefined - - const totalCount = swrData.data?.count - const nextToken = swrData.data?.next - - return { - get metrics() { - return camelMetrics - }, - get count() { - return totalCount - }, - get next() { - return nextToken - }, - get isLoading() { - return !swrData.error && !swrData.data - }, - get isError() { - return !!swrData.error - }, - swrData, - mutate: () => swrData.mutate(), - createScenarioMetrics, - updateMetric, - updateMetrics, - computeRunMetrics, - } -} - -export default useEvaluationRunMetrics diff --git a/web/oss/src/lib/hooks/useEvaluationRunMetrics/types.ts b/web/oss/src/lib/hooks/useEvaluationRunMetrics/types.ts deleted file mode 100644 index 20de372a60..0000000000 --- a/web/oss/src/lib/hooks/useEvaluationRunMetrics/types.ts +++ /dev/null @@ -1,75 +0,0 @@ -import {EvaluationStatus, SnakeToCamelCaseKeys} from "@/oss/lib/Types" - -// Raw API response type for one metric (snake_case) -export interface MetricResponse { - id: string - run_id: string - scenario_id?: string - status?: EvaluationStatus - data: { - outputs: Record - } - created_at?: string - // …other fields in snake_case if backend adds more… -} - -// CamelCased version of MetricResponse -export type Metric = SnakeToCamelCaseKeys - -// Options for fetching metrics (pagination & filters) -export interface UseEvaluationRunMetricsOptions { - limit?: number - next?: string - scenarioIds?: string[] - statuses?: string[] -} - -// Result returned by useEvaluationRunMetrics hook -export interface UseEvaluationRunMetricsResult { - metrics: Metric[] | undefined - count?: number - next?: string - isLoading: boolean - isError: boolean - swrData: import("swr").SWRResponse< - { - metrics: MetricResponse[] - count: number - next?: string - }, - any - > - mutate: () => Promise - createScenarioMetrics: ( - apiUrl: string, - jwt: string, - runId: string, - entries: { - scenarioId: string - data: Record - }[], - ) => Promise - updateMetric: ( - apiUrl: string, - jwt: string, - metricId: string, - changes: { - data?: Record - status?: string - tags?: Record - meta?: Record - }, - ) => Promise - updateMetrics: ( - apiUrl: string, - jwt: string, - metrics: { - id: string - data?: Record - status?: string - tags?: Record - meta?: Record - }[], - ) => Promise - computeRunMetrics: (metrics: {data: Record}[]) => Record -} diff --git a/web/oss/src/lib/metrics/utils.ts b/web/oss/src/lib/metrics/utils.ts index 862ebb1158..ac5f3c21c9 100644 --- a/web/oss/src/lib/metrics/utils.ts +++ b/web/oss/src/lib/metrics/utils.ts @@ -1,4 +1,4 @@ -import {canonicalizeMetricKey, getMetricDisplayName} from "@/oss/lib/metricUtils" +import {canonicalizeMetricKey, getMetricDisplayName} from "@agenta/shared/metrics" // Shared helpers for metric key humanisation and sorting // ------------------------------------------------------ diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/annotations/[queue_id].tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/annotations/[queue_id].tsx index 21f01b21e9..2bf6dea39f 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/annotations/[queue_id].tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/annotations/[queue_id].tsx @@ -8,12 +8,12 @@ import { type MetricPopoverWrapperProps, } from "@agenta/annotation-ui/context" import AnnotationSession from "@agenta/annotation-ui/session" +import {MetricDetailsPreviewPopover} from "@agenta/evaluations-ui" import {useSetAtom} from "jotai" import {useRouter} from "next/router" import AnnotationTestcaseContent from "@/oss/components/Annotations/AnnotationTestcaseContent" import AnnotationTraceContent from "@/oss/components/Annotations/AnnotationTraceContent" -import MetricDetailsPreviewPopover from "@/oss/components/Evaluations/components/MetricDetailsPreviewPopover" import { openTraceDrawerAtom, setTraceDrawerActiveSpanAtom, diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx index 1b8082dc53..05753ee6fa 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/results/[evaluation_id]/index.tsx @@ -1,6 +1,6 @@ import {useRouter} from "next/router" -import EvalRunDetailsPage from "@/oss/components/EvalRunDetails/test" +import EvalRunDetailsPage from "@/oss/components/pages/evaluations/EvalRunDetailsTestPage" const AppEvaluationResultsPage = () => { const router = useRouter() diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/single_model_test/[evaluation_id]/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/single_model_test/[evaluation_id]/index.tsx index 8f6baf5c01..f6a3d8c4b4 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/single_model_test/[evaluation_id]/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/evaluations/single_model_test/[evaluation_id]/index.tsx @@ -1,4 +1,4 @@ -import EvalRunDetailsPage from "@/oss/components/EvalRunDetails/test" +import EvalRunDetailsPage from "@/oss/components/pages/evaluations/EvalRunDetailsTestPage" const EvaluationPage = () => { return diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx index 3315d7c01e..0bd881769c 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/apps/[app_id]/overview/index.tsx @@ -11,6 +11,7 @@ import dynamic from "next/dynamic" import useCustomWorkflowConfig from "@/oss/components/pages/app-management/modals/CustomWorkflowModal/hooks/useCustomWorkflowConfig" import {openDeleteAppModalAtom} from "@/oss/components/pages/app-management/modals/DeleteAppModal/store/deleteAppModalStore" +import EvalRunsViewHost from "@/oss/components/pages/evaluations/EvalRunsViewHost" // TEMPORARY: Disabling name editing // import {openEditAppModalAtom} from "@/oss/components/pages/app-management/modals/EditAppModal/store/editAppModalStore" import DeploymentOverview from "@/oss/components/pages/overview/deployments/DeploymentOverview" @@ -28,7 +29,7 @@ const ObservabilityOverview: any = dynamic( () => import("@/oss/components/pages/overview/observability/ObservabilityOverview"), ) const LatestEvaluationRunsTable: any = dynamic(() => - import("@/oss/components/EvaluationRunsTablePOC").then((m) => m.LatestEvaluationRunsTable), + import("@agenta/evaluations-ui").then((m) => m.LatestEvaluationRunsTable), ) const {Title} = Typography @@ -151,20 +152,22 @@ const OverviewContent = () => { {!isEvaluator ? : null} - - + + + + { const router = useRouter() diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/evaluations/single_model_test/[evaluation_id]/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/evaluations/single_model_test/[evaluation_id]/index.tsx index 45d3efb1f6..62a77d8ab9 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/evaluations/single_model_test/[evaluation_id]/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/evaluations/single_model_test/[evaluation_id]/index.tsx @@ -1,4 +1,4 @@ -import EvalRunDetailsPage from "@/oss/components/EvalRunDetails/test" +import EvalRunDetailsPage from "@/oss/components/pages/evaluations/EvalRunDetailsTestPage" const ProjectHumanEvaluationPage = () => { return diff --git a/web/oss/src/services/evaluationRuns/api/types.ts b/web/oss/src/services/evaluationRuns/api/types.ts deleted file mode 100644 index 88b9c7f65e..0000000000 --- a/web/oss/src/services/evaluationRuns/api/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type {Workflow} from "@agenta/entities/workflow" - -import type {Testset as BaseTestset} from "@/oss/lib/Types" - -// Extend the base Testset to include optional variantId and revisionId -export interface Testset extends BaseTestset { - variantId?: string - revisionId?: string -} - -export interface CreateEvaluationRunInput { - name: string - testset: Testset | undefined - revisions: Workflow[] - evaluators?: Workflow[] - correctAnswerColumn: string - meta?: Record -} diff --git a/web/oss/src/services/evaluationRuns/utils.ts b/web/oss/src/services/evaluationRuns/utils.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/web/oss/src/services/evaluations/api/index.ts b/web/oss/src/services/evaluations/api/index.ts index cc09fe113d..1a5f69a080 100644 --- a/web/oss/src/services/evaluations/api/index.ts +++ b/web/oss/src/services/evaluations/api/index.ts @@ -1,8 +1,10 @@ +import {EvaluationStatus} from "@agenta/entities/evaluationRun" + import type {EvaluationConcurrencySettings} from "@/oss/components/pages/evaluations/NewEvaluation/types" import axios from "@/oss/lib/api/assets/axiosConfig" import {calcEvalDuration} from "@/oss/lib/evaluations/legacy" import {assertValidId, isValidId} from "@/oss/lib/helpers/serviceValidations" -import {EvaluationStatus, KeyValuePair, _Evaluation, _EvaluationScenario} from "@/oss/lib/Types" +import {KeyValuePair, _Evaluation, _EvaluationScenario} from "@/oss/lib/Types" import {getProjectValues} from "@/oss/state/project" //Prefix convention: diff --git a/web/oss/src/services/evaluations/invocations/api.ts b/web/oss/src/services/evaluations/invocations/api.ts deleted file mode 100644 index 60e6233d7c..0000000000 --- a/web/oss/src/services/evaluations/invocations/api.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * API helpers for persisting invocation results in evaluation scenarios. - * - * Note: The actual HTTP invocation is now handled by `executeWorkflowRevision` - * from `@agenta/playground`, which uses the full playground execution - * infrastructure (workflowMolecule URL resolution, concurrency limiting, etc.). - * - * This module provides only the persistence helpers that write trace/span - * references and status updates back to the evaluation API. - */ - -import axios from "@/oss/lib/api/assets/axiosConfig" -import {EvaluationStatus} from "@/oss/lib/Types" -import {getProjectValues} from "@/oss/state/project" - -const RESULTS_ENDPOINT = "/evaluations/results/" - -export interface InvocationReferences { - application?: {id: string} - application_variant?: {id: string} - application_revision?: {id: string} -} - -/** - * Convert a hex string (32 chars) to UUID format (with dashes). - */ -const hexToUuid = (hex: string): string => { - if (hex.includes("-")) return hex - if (hex.length !== 32) return hex - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - -/** - * Convert a hex span ID (16 chars) to UUID format by doubling it. - */ -const spanHexToUuid = (hex: string): string => { - if (hex.includes("-")) return hex - if (hex.length === 16) { - const doubled = hex + hex - return `${doubled.slice(0, 8)}-${doubled.slice(8, 12)}-${doubled.slice(12, 16)}-${doubled.slice(16, 20)}-${doubled.slice(20)}` - } - if (hex.length === 32) { - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` - } - return hex -} - -/** - * Upsert a step result with invocation trace/span reference and status. - */ -export const upsertStepResultWithInvocation = async ({ - runId, - scenarioId, - stepKey, - traceId, - spanId, - status, - references, - outputs, - error, -}: { - runId: string - scenarioId: string - stepKey: string - traceId?: string - spanId?: string - status: string - references?: InvocationReferences - outputs?: unknown - error?: {message: string; stacktrace?: string} -}): Promise => { - const {projectId} = getProjectValues() - - // Convert hex IDs to UUID format if provided - const traceIdUuid = traceId ? hexToUuid(traceId) : undefined - const spanIdUuid = spanId ? spanHexToUuid(spanId) : undefined - - const resultPayload: Record = {status} - - if (traceIdUuid) { - resultPayload.trace_id = traceIdUuid - } - if (spanIdUuid) { - resultPayload.span_id = spanIdUuid - } - if (references) { - resultPayload.references = references - } - if (outputs !== undefined) { - resultPayload.outputs = outputs - } - if (error) { - resultPayload.error = error - } - - // The setter upserts on the natural key (run_id, scenario_id, step_key, - // repeat_idx), so a single POST handles both create and edit — no `id` needed. - const result = { - run_id: runId, - scenario_id: scenarioId, - step_key: stepKey, - ...resultPayload, - } - - await axios.post(`${RESULTS_ENDPOINT}?project_id=${projectId}`, {results: [result]}) -} - -/** - * Update a scenario's status. - */ -export const updateScenarioStatus = async ( - scenarioId: string, - status: EvaluationStatus, -): Promise => { - const {projectId} = getProjectValues() - - try { - await axios.patch(`/evaluations/scenarios/?project_id=${projectId}`, { - scenarios: [{id: scenarioId, status}], - }) - } catch (error) { - console.error("[updateScenarioStatus] Failed to update scenario status:", error) - } -} diff --git a/web/oss/src/services/evaluations/results/api.ts b/web/oss/src/services/evaluations/results/api.ts deleted file mode 100644 index 623039ff9a..0000000000 --- a/web/oss/src/services/evaluations/results/api.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * API functions for evaluation results (steps). - * These functions use axios with automatic project ID injection. - */ - -import axios from "@/oss/lib/api/assets/axiosConfig" -import {getProjectValues} from "@/oss/state/project" - -const RESULTS_ENDPOINT = "/evaluations/results/" - -/** - * Convert a hex string (32 chars) to UUID format (with dashes) - */ -const hexToUuid = (hex: string): string => { - // If already in UUID format (contains dashes), return as-is - if (hex.includes("-")) return hex - // If not 32 chars, return as-is (invalid hex) - if (hex.length !== 32) return hex - // Insert dashes at positions 8, 12, 16, 20 - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` -} - -/** - * Convert a hex span ID (16 chars) to UUID format by doubling it - */ -const spanHexToUuid = (hex: string): string => { - // If already in UUID format (contains dashes), return as-is - if (hex.includes("-")) return hex - // If 16 chars (span hex), double it to make 32 chars - if (hex.length === 16) { - const doubled = hex + hex - return `${doubled.slice(0, 8)}-${doubled.slice(8, 12)}-${doubled.slice(12, 16)}-${doubled.slice(16, 20)}-${doubled.slice(20)}` - } - // If 32 chars, convert to UUID - if (hex.length === 32) { - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` - } - return hex -} - -export interface StepResult { - id?: string - run_id: string - scenario_id: string - step_key: string - status: string - trace_id?: string - span_id?: string - references?: Record - data?: Record -} - -export interface QueryResultsParams { - runId: string - scenarioId: string - stepKeys?: string[] -} - -/** - * Query step results for a specific scenario. - */ -export const queryStepResults = async ({ - runId, - scenarioId, - stepKeys, -}: QueryResultsParams): Promise => { - const {projectId} = getProjectValues() - - const response = await axios.post(`${RESULTS_ENDPOINT}query?project_id=${projectId}`, { - result: { - run_ids: [runId], - scenario_ids: [scenarioId], - ...(stepKeys?.length ? {step_keys: stepKeys} : {}), - }, - windowing: {}, - }) - - const data = response.data - return Array.isArray(data.results) ? data.results : Array.isArray(data.steps) ? data.steps : [] -} - -/** - * Update step results (PATCH). - */ -export const updateStepResults = async (results: Partial[]): Promise => { - const {projectId} = getProjectValues() - - return axios.patch(`${RESULTS_ENDPOINT}?project_id=${projectId}`, { - results, - }) -} - -/** - * Create step results (POST). - */ -export const createStepResults = async (results: StepResult[]): Promise => { - const {projectId} = getProjectValues() - - return axios.post(`${RESULTS_ENDPOINT}?project_id=${projectId}`, { - results, - }) -} - -/** - * Upsert a step result with annotation reference. - * This function queries for an existing step result and either updates it or creates a new one. - * - * @param runId - The evaluation run ID - * @param scenarioId - The scenario ID - * @param stepKey - The step key (e.g., "default-xxx.evaluator-slug") - * @param annotationTraceId - The trace ID of the annotation - * @param annotationSpanId - The span ID of the annotation - * @param status - The step status (default: "success") - */ -export const upsertStepResultWithAnnotation = async ({ - runId, - scenarioId, - stepKey, - annotationTraceId, - annotationSpanId, - status = "success", -}: { - runId: string - scenarioId: string - stepKey: string - annotationTraceId: string - annotationSpanId: string - status?: string -}): Promise => { - const {projectId} = getProjectValues() - - // Convert hex IDs to UUID format (the API expects UUIDs with dashes) - // Annotation API returns hex format: "" - // Step result API expects UUID format: "" - const traceIdUuid = hexToUuid(annotationTraceId) - const spanIdUuid = spanHexToUuid(annotationSpanId) - - // The setter upserts on the natural key (run_id, scenario_id, step_key, - // repeat_idx), so a single POST handles both create and edit — no `id` needed. - const result = { - run_id: runId, - scenario_id: scenarioId, - step_key: stepKey, - status, - trace_id: traceIdUuid, - span_id: spanIdUuid, - } - - await axios.post(`${RESULTS_ENDPOINT}?project_id=${projectId}`, {results: [result]}) -} diff --git a/web/oss/src/services/evaluations/scenarios/api.ts b/web/oss/src/services/evaluations/scenarios/api.ts deleted file mode 100644 index b46b938021..0000000000 --- a/web/oss/src/services/evaluations/scenarios/api.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * API functions for managing evaluation scenario and run status. - */ - -import axios from "@/oss/lib/api/assets/axiosConfig" -import {invalidatePreviewRunCache} from "@/oss/lib/hooks/usePreviewEvaluations/assets/previewRunBatcher" -import {getProjectValues} from "@/oss/state/project" - -/** - * Validates that an ID is a safe alphanumeric string with allowed special characters. - * This prevents SSRF attacks by ensuring IDs don't contain URL manipulation characters. - */ -const isValidId = (id: string): boolean => { - // Allow alphanumeric, hyphens, and underscores only (typical UUID/ID format) - // This prevents path traversal and URL manipulation - return /^[a-zA-Z0-9_-]+$/.test(id) -} - -/** - * Update a scenario's status. - * This is safe because EvaluationScenarioEdit only has id and status fields, - * so it won't overwrite any other data. - */ -export const updateScenarioStatus = async (scenarioId: string, status: string): Promise => { - const {projectId} = getProjectValues() - - // Validate scenarioId to prevent SSRF attacks - if (!isValidId(scenarioId)) { - throw new Error("Invalid scenario ID format") - } - - await axios.patch(`/evaluations/scenarios/?project_id=${projectId}`, { - scenarios: [{id: scenarioId, status}], - }) -} - -/** - * Check if all scenarios in a run are complete and update the run status accordingly. - * This fetches the existing run data first to avoid overwriting the data field. - */ -export const checkAndUpdateRunStatus = async (runId: string): Promise => { - const {projectId} = getProjectValues() - - // Validate runId to prevent SSRF attacks - if (!isValidId(runId)) { - throw new Error("Invalid run ID format") - } - - try { - // Query all scenarios for this run - const scenariosResponse = await axios.post( - `/evaluations/scenarios/query?project_id=${projectId}`, - { - scenario: {run_ids: [runId]}, - windowing: {limit: 1000}, - }, - ) - - const scenarios = scenariosResponse.data?.scenarios ?? [] - if (scenarios.length === 0) return - - // Terminal statuses that indicate a scenario is complete - const terminalStatuses = new Set([ - "success", - "error", - "failure", - "failed", - "errors", - "cancelled", - ]) - - // Check if all scenarios have terminal status - const allComplete = scenarios.every((scenario: {status?: string}) => - terminalStatuses.has(scenario.status?.toLowerCase() ?? ""), - ) - - if (!allComplete) return - - // Determine run status based on scenario statuses - const hasErrors = scenarios.some((scenario: {status?: string}) => { - const status = scenario.status?.toLowerCase() ?? "" - return ["error", "failure", "failed", "errors"].includes(status) - }) - - const newRunStatus = hasErrors ? "errors" : "success" - - // Fetch the existing run data first to preserve all fields - const runResponse = await axios.post(`/evaluations/runs/query?project_id=${projectId}`, { - run: {ids: [runId]}, - }) - - const existingRun = runResponse.data?.runs?.[0] - if (!existingRun) return - - // Update run status by sending the complete run object with only status changed - await axios.patch(`/evaluations/runs/${runId}`, { - run: {...existingRun, id: runId, status: newRunStatus}, - }) - - // Invalidate the preview run cache so the header refetches fresh data - if (projectId) { - invalidatePreviewRunCache(projectId, runId) - } - } catch (error) { - console.error("[checkAndUpdateRunStatus] Failed:", error) - } -} diff --git a/web/oss/src/services/evaluations/workerUtils.ts b/web/oss/src/services/evaluations/workerUtils.ts deleted file mode 100644 index d3ac294912..0000000000 --- a/web/oss/src/services/evaluations/workerUtils.ts +++ /dev/null @@ -1,151 +0,0 @@ -import {EvaluationStatus} from "@/oss/lib/Types" - -/** - * Update scenario status from a WebWorker / non-axios context. - */ -export async function updateScenarioStatusRemote( - apiUrl: string, - jwt: string, - scenarioId: string, - status: EvaluationStatus, - projectId: string, - runId?: string, -): Promise { - try { - // 1. Query results to validate scenario context (scenarios GET is deprecated) - const res = await fetch(`${apiUrl}/evaluations/results/query?project_id=${projectId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ - result: { - scenario_ids: [scenarioId], - ...(runId ? {run_ids: [runId]} : {}), - }, - windowing: {}, - }), - }) - let scenarioFull: any | null = null - if (res.ok) { - // We no longer rely on the scenario payload; server requires id for PATCH - // Keep minimal object; if server returns extra data in future, parse here - scenarioFull = {id: scenarioId} - } - if (!scenarioFull) scenarioFull = {id: scenarioId} - scenarioFull.status = status - await fetch(`${apiUrl}/evaluations/scenarios/?project_id=${projectId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({scenarios: [scenarioFull]}), - }) - } catch { - /* swallow */ - } -} - -/** - * Upsert (create or update) a generic scenario step. Can be used for invocation or annotation steps. - */ -export async function upsertScenarioStep(params: { - apiUrl: string - jwt: string - runId: string - scenarioId: string - status: EvaluationStatus - projectId: string - key: string - traceId?: string | null - spanId?: string | null - references?: Record -}): Promise { - const { - apiUrl, - jwt, - runId, - scenarioId, - status, - projectId, - key, - traceId, - spanId, - references = {}, - } = params - try { - const res = await fetch(`${apiUrl}/evaluations/results/query?project_id=${projectId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({ - result: { - run_ids: [runId], - scenario_ids: [scenarioId], - step_keys: [key], - }, - windowing: {}, - }), - }) - if (res.ok) { - const data = await res.json() - const list = Array.isArray(data.results) - ? data.results - : Array.isArray(data.steps) - ? data.steps - : [] - const existing = list.find((s: any) => s.step_key === key || s.stepKey === key) - if (existing) { - const updated = { - ...existing, - status, - trace_id: traceId, - span_id: spanId, - references: {...((existing as any)?.references || {}), ...references}, - } - await fetch(`${apiUrl}/evaluations/results/?project_id=${projectId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - // API expects bulk-style body: { results: [ { id, ...fields } ] } - body: JSON.stringify({results: [updated]}), - }) - return - } - } - } catch { - /* fallthrough to creation */ - } - - const body = { - results: [ - { - status, - step_key: key, - trace_id: traceId, - span_id: spanId, - scenario_id: scenarioId, - run_id: runId, - references, - }, - ], - } - try { - await fetch(`${apiUrl}/evaluations/results/?project_id=${projectId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(body), - }) - } catch { - /* ignore */ - } -} diff --git a/web/oss/src/services/runMetrics/api/assets/contants.ts b/web/oss/src/services/runMetrics/api/assets/contants.ts deleted file mode 100644 index f1d8278bd0..0000000000 --- a/web/oss/src/services/runMetrics/api/assets/contants.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const PERCENTILE_STOPS = [ - 0.05, 0.1, 0.5, 1, 2.5, 5, 10, 12.5, 20, 25, 30, 37.5, 40, 50, 60, 62.5, 70, 75, 80, 87.5, 90, - 95, 97.5, 99, 99.5, 99.9, 99.95, -] - -// Inter-quartile ranges aligned with backend mapping -export const iqrsLevels: Record = { - iqr25: ["p37.5", "p62.5"], - iqr50: ["p25", "p75"], - iqr60: ["p20", "p80"], - iqr75: ["p12.5", "p87.5"], - iqr80: ["p10", "p90"], - iqr90: ["p5", "p95"], - iqr95: ["p2.5", "p97.5"], - iqr98: ["p1", "p99"], - iqr99: ["p0.5", "p99.5"], - "iqr99.9": ["p0.05", "p99.95"], -} diff --git a/web/oss/src/services/runMetrics/api/index.ts b/web/oss/src/services/runMetrics/api/index.ts deleted file mode 100644 index f175601fdd..0000000000 --- a/web/oss/src/services/runMetrics/api/index.ts +++ /dev/null @@ -1,811 +0,0 @@ -import axios from "@/oss/lib/api/assets/axiosConfig" -import {getAgentaApiUrl} from "@/oss/lib/helpers/api" -import {getProjectValues} from "@/oss/state/project" - -import {iqrsLevels, PERCENTILE_STOPS} from "./assets/contants" -import {BasicStats} from "./types" - -export const METRICS_ENDPOINT = "/evaluations/metrics/" - -const fetchJSON = async (url: string, options: RequestInit) => { - const res = await fetch(url, options) - if (!res.ok) throw new Error(res.statusText) - return res.json() -} - -// /** -// * Create a new run-level metric entry. -// * -// * @param apiUrl The URL of the API service to create the metric against. -// * @param jwt The JWT token to authenticate the request. -// * @param runId The UUID of the evaluation run to associate with the metric. -// * @param data A dictionary of string keys to numeric values representing the -// * metric data. -// * -// * @returns The newly created metric object (snake_case). -// */ -// export const createRunMetrics = async ( -// apiUrl: string, -// jwt: string, -// runId: string, -// data: Record, -// projectId: string, -// ) => { -// const payload = {metrics: [{run_id: runId, data}]} -// return fetchJSON(`${apiUrl}${METRICS_ENDPOINT}?project_id=${projectId}`, { -// method: "POST", -// headers: { -// "Content-Type": "application/json", -// Authorization: `Bearer ${jwt}`, -// }, -// body: JSON.stringify(payload), -// }) -// } - -/** - * Creates a new run-level metric or updates an existing one. - * - * This function will first attempt to fetch the existing metric associated - * with the given runId. If a metric is found, it will be updated with the - * new data. If no existing metric is found, a new metric entry will be - * created. - * - * @param apiUrl The base URL of the API service. - * @param jwt The JWT token used for authenticating the request. - * @param runId The UUID of the evaluation run to associate with the metrics. - * @param data A dictionary of string keys to numeric values representing the - * metric data. - * - * @returns The newly created or updated metric object (snake_case). - */ -// export const upsertRunMetrics = async ( -// apiUrl: string, -// jwt: string, -// runId: string, -// data: Record, -// projectId: string, -// ) => { -// try { -// const params = new URLSearchParams({ -// run_ids: runId, -// }) -// const res = await fetchJSON(`${apiUrl}${METRICS_ENDPOINT}?${params.toString()}`, { -// headers: {Authorization: `Bearer ${jwt}`}, -// }) -// const existing = Array.isArray(res.metrics) ? res.metrics[0] : undefined -// if (existing) { -// const merged = {...(existing.data || {}), ...data} -// return updateMetric(apiUrl, jwt, existing.id, { -// data: merged, -// status: existing.status || "finished", -// tags: existing.tags, -// meta: existing.meta, -// }) -// } -// } catch { -// /* ignore lookup errors and fall back to creation */ -// } -// return createRunMetrics(apiUrl, jwt, runId, data, projectId) -// } - -/** - * Create or update scenario-level metrics for a specific evaluation run. - * - * This function takes a list of scenario metric entries and attempts to - * either create new metrics or update existing ones based on the provided - * runId and scenarioId. If a metric already exists for a given scenario, - * it is updated with the new data. If no existing metric is found, a new - * metric entry is created. - * - * @param apiUrl The base URL of the API service. - * @param jwt The JWT token used for authenticating the request. - * @param runId The UUID of the evaluation run to associate with the metrics. - * @param entries An array of objects containing scenarioId and data to - * be stored as metrics. - * - * @returns A promise that resolves when all create or update operations - * have been completed. - */ -export const createScenarioMetrics = async ( - apiUrl: string, - jwt: string, - runId: string, - entries: {scenarioId: string; data: Record}[], - projectId: string, -) => { - const toCreate: {run_id: string; scenario_id: string; data: Record}[] = [] - const toUpdate: { - id: string - data: Record - status?: string - tags?: Record - meta?: Record - }[] = [] - - const queryUrl = `${apiUrl}${METRICS_ENDPOINT}query?project_id=${projectId}` - const existingByScenario: Record = {} - - try { - const payload = { - metrics: { - run_ids: [runId], - scenario_ids: entries.map((entry) => entry.scenarioId), - }, - windowing: {}, - } - - const queryResponse = await fetchJSON(queryUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) - - const existingMetrics = Array.isArray(queryResponse?.metrics) ? queryResponse.metrics : [] - - existingMetrics.forEach((metric: any) => { - const scenarioId = metric?.scenario_id || metric?.scenarioId - if (scenarioId) { - existingByScenario[scenarioId] = metric - } - }) - } catch (error) { - console.warn("[createScenarioMetrics] Failed to query existing metrics", error) - } - - for (const entry of entries) { - const existing = existingByScenario[entry.scenarioId] - if (existing) { - const mergedData = { - ...(existing.data || {}), - ...entry.data, - } - if (existing.id) { - toUpdate.push({ - id: existing.id, - data: mergedData, - status: existing.status, - tags: existing.tags, - meta: existing.meta, - }) - continue - } - } - toCreate.push({run_id: runId, scenario_id: entry.scenarioId, data: entry.data}) - } - - const promises: Promise[] = [] - if (toCreate.length) { - promises.push( - fetchJSON(`${apiUrl}${METRICS_ENDPOINT}?project_id=${projectId}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({metrics: toCreate}), - }), - ) - } - if (toUpdate.length) { - promises.push( - fetchJSON(`${apiUrl}${METRICS_ENDPOINT}?project_id=${projectId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({metrics: toUpdate}), - }), - ) - } - return Promise.all(promises) -} - -/** - * Update a single metric entry. - * - * @param apiUrl The URL of the API service to create the metric against. - * @param jwt The JWT token to authenticate the request. - * @param metricId The UUID of the metric to update. - * @param changes A dictionary of changes to apply to the metric. - * - * @returns The updated metric object (snake_case). - */ -export const updateMetric = async ( - apiUrl: string, - jwt: string, - metricId: string, - changes: { - data?: Record - status?: string - tags?: Record - meta?: Record - }, - projectId: string, -) => { - const payload = {metric: {id: metricId, ...changes}} - return fetchJSON(`${apiUrl}${METRICS_ENDPOINT}${metricId}?project_id=${projectId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify(payload), - }) -} - -/** - * Update multiple metric entries. - * - * @param apiUrl The URL of the API service to update the metrics against. - * @param jwt The JWT token to authenticate the request. - * @param metrics An array of metric objects to update. Each object should contain - * at least an 'id' property and may contain additional properties - * to update ('data', 'status', 'tags', 'meta'). - * - * @returns An array of the updated metric objects (snake_case). - */ -export const updateMetrics = async ( - apiUrl: string, - jwt: string, - metrics: { - id: string - data?: Record - status?: string - tags?: Record - meta?: Record - }[], - projectId: string, -) => { - return fetchJSON(`${apiUrl}${METRICS_ENDPOINT}?project_id=${projectId}`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - }, - body: JSON.stringify({metrics}), - }) -} - -// --- Statistics helpers -------------------------------------------------- - -/** - * Calculates the p-th percentile of a sorted array of numbers. - * - * @param sorted - An array of numbers sorted in ascending order. - * @param p - The percentile to calculate (between 0 and 100). - * @returns The calculated percentile value. - * If the array is empty, returns 0. - */ -function percentile(sorted: number[], p: number): number { - if (sorted.length === 0) return 0 - const idx = (p / 100) * (sorted.length - 1) - const lower = Math.floor(idx) - const upper = Math.ceil(idx) - if (lower === upper) return sorted[lower] - const weight = idx - lower - return sorted[lower] * (1 - weight) + sorted[upper] * weight -} - -// Helper: round to 'p' decimal places (default 6) and coerce back to number -// Smart rounding: for numbers < 0.001 use significant–figure precision to -// avoid long binary tails; otherwise use fixed decimal rounding. -const round = (v: number, p = 6, sig = 6): number => { - if (Number.isNaN(v)) return v - const abs = Math.abs(v) - if (abs !== 0 && abs < 1e-3) { - return Number(v.toPrecision(sig)) - } - return Number(v.toFixed(p)) -} - -/** - * Builds a histogram distribution from an array of numbers. - * - * This function calculates a histogram by determining the optimal number of bins - * based on the square root of the number of input values. It then computes the - * bin size and assigns each number to a bin. The resulting histogram is returned - * as an array of objects, each containing a bin start value and the count of - * numbers in that bin. - * - * @param values - An array of numbers to create the distribution from. - * @returns An array of objects where each object represents a bin with the - * 'value' as the bin start and 'count' as the number of elements - * in that bin. If all values are the same, returns a single bin - * with the value and the count of elements. - */ -function buildDistribution(values: number[]): {value: number; count: number}[] { - if (!values.length) return [] - - const n = values.length - const bins = Math.ceil(Math.sqrt(n)) - const min = Math.min(...values) - const max = Math.max(...values) - - if (min === max) { - return [{value: round(min, 6), count: n}] - } - - const binSize = (max - min) / bins - // precision = number of decimal places required to keep bin starts stable - const precision = binSize ? Math.max(0, -Math.floor(Math.log10(binSize))) : 0 - - const hist = new Map() - - values.forEach((v) => { - let binIndex = Math.floor((v - min) / binSize) - if (binIndex === bins) binIndex -= 1 // edge case when v === max - const binStart = Number((min + binIndex * binSize).toFixed(precision)) - hist.set(binStart, (hist.get(binStart) ?? 0) + 1) - }) - - return Array.from(hist.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([value, count]) => ({value, count})) -} - -/** - * Computes various statistical measures for a given array of numbers. - * - * @param values - An array of numbers for which statistics are to be computed. - * @returns An object containing the following statistical measures: - * - count: The number of elements in the array. - * - sum: The total sum of the elements. - * - mean: The average value of the elements. - * - min: The minimum value in the array. - * - max: The maximum value in the array. - * - range: The difference between the maximum and minimum values. - * - distribution: A histogram representation of the values. - * - percentiles: An object containing percentile values for defined stops. - * - iqrs: An object containing inter-quartile ranges as per backend mapping. - */ -function computeStats(values: number[]): BasicStats { - const count = values.length - if (count === 0) { - return { - count: 0, - sum: 0, - mean: 0, - min: 0, - max: 0, - range: 0, - distribution: [], - percentiles: {}, - iqrs: {}, - } - } - - const sorted = [...values].sort((a, b) => a - b) - const sum = values.reduce((acc, v) => acc + v, 0) - const mean = sum / count - const min = sorted[0] - const max = sorted[sorted.length - 1] - const range = max - min - - // Percentiles with rounded output - const percentiles: Record = {} - PERCENTILE_STOPS.forEach((p) => { - percentiles[`p${p}`] = round(percentile(sorted, p), 4) - }) - - const iqrs: Record = {} - Object.entries(iqrsLevels).forEach(([label, [low, high]]) => { - iqrs[label] = round(percentiles[high] - percentiles[low], 4) - }) - - const distribution = buildDistribution(values) - const bins = distribution.length - const binSize = bins ? (range !== 0 ? range / bins : 1) : undefined - - return { - count, - sum: round(sum, 6), - mean: round(mean, 6), - min: round(min, 6), - max: round(max, 6), - range: round(range, 6), - distribution, - percentiles, - iqrs, - binSize: binSize !== undefined ? round(binSize, 6) : undefined, - } -} - -// --- Additional helpers for non-numeric metrics ------------------------- - -// Count of values -function count(values: unknown[]): number { - return values.length -} - -// Build frequency list [{value,count}] -function buildFrequency(values: unknown[]): {value: any; count: number}[] { - const freqMap = new Map() - values.forEach((v) => freqMap.set(v, (freqMap.get(v) ?? 0) + 1)) - return Array.from(freqMap.entries()).map(([value, count]) => ({value, count})) -} - -function buildRank(values: unknown[], topK = 10): {value: any; count: number}[] { - return buildFrequency(values) - .sort((a, b) => b.count - a.count) - .slice(0, topK) -} - -function processBinary(values: (boolean | null)[]): BasicStats { - const filtered = values.map((v) => (v === null || v === undefined ? null : v)) - return { - count: count(filtered), - frequency: buildFrequency(filtered), - unique: Array.from(new Set(filtered)), - rank: buildRank(filtered), - } -} - -function processClass(values: (string | number | boolean | null)[]): BasicStats { - return { - count: count(values), - frequency: buildFrequency(values), - unique: Array.from(new Set(values)), - rank: buildRank(values), - } -} - -function processLabels(values: ((string | number | boolean | null)[] | null)[]): BasicStats { - // Flatten labels list - const flat: (string | number | boolean | null)[] = [] - values.forEach((arr) => { - if (Array.isArray(arr)) flat.push(...arr) - else flat.push(null) - }) - // Additionally compute distribution of label counts per record - // const labelCounts = values.map((arr) => (Array.isArray(arr) ? arr.length : 0)) - // const distStats = computeStats(labelCounts) - // const labelValueDistribution = buildFrequency(flat).map((f) => ({ - // value: f.value, - // count: f.count, - // })) - const returnData = { - count: count(flat), - frequency: buildFrequency(flat), - unique: Array.from(new Set(flat)), - rank: buildRank(flat), - } - return returnData -} - -// TODO: Clean this up Ashraf -// Implemented this to handle boolean metric for auto eval -interface BoolCount { - count: number - value: boolean -} -interface ItemShape { - rank?: BoolCount[] - frequency?: BoolCount[] - count?: number // not required for aggregation - unique?: boolean[] // not required for aggregation -} - -interface Summary { - rank: BoolCount[] - count: number - unique: boolean[] - frequency: BoolCount[] -} - -export function aggregateBooleanSummaryByVote(items: ItemShape[]): Summary { - let totalItems = 0 - let votesTrue = 0 - let votesFalse = 0 - - for (const item of items) { - // Prefer rank if present, else fall back to frequency - const source = (item.rank?.length ? item.rank : item.frequency) ?? [] - - if (!source.length) continue - - // Pick the winner for THIS item: - // - If item.rank was provided, assume it's already sorted (winner is source[0]) - // - Otherwise, find the max by count from frequency - let winner: BoolCount | undefined - - if (item.rank?.length) { - winner = source[0] - } else { - winner = source.reduce((best, cur) => { - if (!best) return cur - if (cur.count > best.count) return cur - if (cur.count === best.count) { - // Tie-break: prefer the one that appears first (stable), or prefer true. - // To prefer true on ties, use the following line instead: - // return cur.value === true ? cur : best; - return best - } - return best - }, undefined) - } - - if (winner && typeof winner.value === "boolean") { - totalItems += 1 // this item contributes exactly one vote - if (winner.value) votesTrue += 1 - else votesFalse += 1 - } - } - - // Build totals; keep rank/frequency consistent and sorted by count desc (tie: true first) - const totals: BoolCount[] = [ - {value: true, count: votesTrue}, - {value: false, count: votesFalse}, - ].sort((a, b) => b.count - a.count || (a.value === true ? -1 : 1)) - - return { - rank: totals, - count: totalItems, // <= items.length - unique: [true, false], - frequency: totals, - } -} - -// ------------------------------------------------------------------------ - -/** - * Computes a map of metrics to their computed statistics, given a list of - * objects with `data` properties containing key-value pairs of metric names - * to their respective values. - * - * It will group values by metric key, and compute the following statistics - * for each key: - * - * - `count`: The number of values. - * - `sum`: The sum of all values. - * - `mean`: The mean of all values. - * - `min`: The minimum value. - * - `max`: The maximum value. - * - `range`: The difference between the maximum and minimum values. - * - `distribution`: An array of 11 values representing the distribution of - * values between the minimum and maximum. - * - `percentiles`: An object with keys `pX` where `X` is a percentile (e.g. - * `p25`, `p50`, `p75`), and values that are the corresponding percentiles - * of the values. - * - `iqrs`: An object with keys that are the names of interquartile ranges - * (e.g. `iqr25`, `iqr50`, `iqr75`), and values that are the corresponding - * interquartile ranges of the values. - * - * @param metrics An array of objects with `data` properties containing key-value pairs of metric names to their respective values. - * @returns An object with metric names as keys, and their computed statistics as values. - */ -export const computeRunMetrics = (metrics: {data: Record}[]): Record => { - if (!metrics?.length) return {} - - const result: Record = {} - const valueBuckets: Record = {} - - metrics.forEach((m) => { - Object.entries(m.data || {}).forEach(([k, v]) => { - if (v !== undefined) { - valueBuckets[k] = valueBuckets[k] || [] - valueBuckets[k].push(v) - } - }) - }) - - // Process non-special keys - Object.entries(valueBuckets).forEach(([k, values]) => { - const allNumbers = values.every((v) => typeof v === "number" && !isNaN(v)) - const allBooleans = values.every((v) => typeof v === "boolean" || v === null) - const proccesdBooleans = values.every( - (v) => v?.unique?.length && typeof v?.unique?.[0] === "boolean", - ) - const allArrays = values.every((v) => Array.isArray(v)) - const allStatsObjects = values.every( - (v) => - v && - typeof v === "object" && - !Array.isArray(v) && - ("mean" in (v as any) || - "sum" in (v as any) || - "count" in (v as any) || - "frequency" in (v as any) || - "rank" in (v as any)), - ) - - if (allNumbers) { - result[k] = computeStats(values as number[]) - } else if (allBooleans) { - result[k] = processBinary(values as (boolean | null)[]) - } else if (proccesdBooleans) { - result[k] = aggregateBooleanSummaryByVote(values) - } else if (allArrays) { - result[k] = processLabels(values as any[][]) // treat as labels metric - } else if (allStatsObjects) { - const merged = values.reduce((acc: any, current: any) => { - if (!acc) return current - const next: any = {...acc} - if (typeof current.mean === "number") next.mean = current.mean - if (typeof current.sum === "number") next.sum = current.sum - if (typeof current.count === "number") { - next.count = (next.count ?? 0) + (current.count ?? 0) - } - if (Array.isArray(current.frequency)) next.frequency = current.frequency - if (Array.isArray(current.rank)) next.rank = current.rank - if (Array.isArray(current.unique)) next.unique = current.unique - if (Array.isArray(current.distribution)) next.distribution = current.distribution - if (current.percentiles) next.percentiles = current.percentiles - if (current.iqrs) next.iqrs = current.iqrs - if (typeof current.binSize === "number") next.binSize = current.binSize - return next - }, null) - const finalStats = merged ?? values[0] - if (finalStats && Array.isArray(finalStats.frequency)) { - finalStats.frequency = finalStats.frequency.map((entry: any) => ({ - value: entry?.value, - count: entry?.count ?? entry?.frequency ?? 0, - })) - finalStats.frequency.sort( - (a: any, b: any) => b.count - a.count || (a.value === true ? -1 : 1), - ) - finalStats.rank = finalStats.frequency - if (!Array.isArray(finalStats.unique) || !finalStats.unique.length) { - finalStats.unique = finalStats.frequency.map((entry: any) => entry.value) - } - } - result[k] = finalStats - } else if ( - values.every( - (v) => - v === null || - typeof v === "string" || - typeof v === "number" || - typeof v === "boolean", - ) - ) { - result[k] = processClass(values as any[]) - } - }) - - return result -} - -export interface MetricDistribution { - distribution: {value: number; count: number}[] - mean: number - min: number - max: number - binSize: number -} - -export const computeMetricDistribution = ( - values: number[], - stats?: BasicStats, -): MetricDistribution | undefined => { - let computed = stats - if (!computed) { - if (!values.length) return undefined - const tmpKey = "__metric" - const agg = computeRunMetrics(values.map((v) => ({data: {[tmpKey]: v}}))) - computed = agg[tmpKey] - } - if (!computed?.distribution || !computed.distribution.length) { - return computed - } - let binSize = computed.binSize - if (binSize === undefined) { - const bins = computed.distribution.length - const range = computed.range ?? (computed.max ?? 0) - (computed.min ?? 0) - binSize = bins ? (range !== 0 ? range / bins : 1) : 1 - } - return { - distribution: computed.distribution, - mean: computed.mean ?? 0, - min: computed.min ?? 0, - max: computed.max ?? 0, - binSize, - } -} - -// --- Axios-based API functions (for use in components) --- - -/** - * Query scenario metrics for a specific run and scenario. - * Uses axios with automatic project ID injection. - */ -export const queryScenarioMetric = async ({ - runId, - scenarioId, -}: { - runId: string - scenarioId: string -}): Promise<{metrics: any[]}> => { - const {projectId} = getProjectValues() - const apiUrl = getAgentaApiUrl() - - const response = await axios.post(`${apiUrl}${METRICS_ENDPOINT}query?project_id=${projectId}`, { - metrics: { - run_ids: [runId], - scenario_ids: [scenarioId], - }, - windowing: {}, - }) - - return response.data -} - -/** - * Create or update scenario-level metrics using axios. - * This function queries existing metrics and either creates or updates them. - * - * @param runId - The evaluation run ID - * @param scenarioId - The scenario ID - * @param data - The metric data to store (stepKey -> metricKey -> metricData) - */ -export const upsertScenarioMetricData = async ({ - runId, - scenarioId, - data, -}: { - runId: string - scenarioId: string - data: Record> -}): Promise => { - const {projectId} = getProjectValues() - const apiUrl = getAgentaApiUrl() - - // First, query existing metrics for this scenario - let existingMetric: any = null - try { - const queryResponse = await axios.post( - `${apiUrl}${METRICS_ENDPOINT}query?project_id=${projectId}`, - { - metrics: { - run_ids: [runId], - scenario_ids: [scenarioId], - }, - windowing: {}, - }, - ) - - const existingMetrics = Array.isArray(queryResponse?.data?.metrics) - ? queryResponse.data.metrics - : [] - existingMetric = existingMetrics.find( - (m: any) => (m?.scenario_id || m?.scenarioId) === scenarioId, - ) - } catch (error) { - console.warn("[upsertScenarioMetricData] Failed to query existing metrics", error) - } - - // Merge new data with existing data - const mergedData = { - ...(existingMetric?.data || {}), - ...data, - } - - // Update existing or create new - if (existingMetric?.id) { - // Update existing metric - return axios.patch(`${apiUrl}${METRICS_ENDPOINT}?project_id=${projectId}`, { - metrics: [ - { - id: existingMetric.id, - data: mergedData, - status: existingMetric.status || "success", - }, - ], - }) - } else { - // Create new metric - return axios.post(`${apiUrl}${METRICS_ENDPOINT}?project_id=${projectId}`, { - metrics: [ - { - run_id: runId, - scenario_id: scenarioId, - data: mergedData, - status: "success", - }, - ], - }) - } -} diff --git a/web/oss/src/services/runMetrics/api/types.ts b/web/oss/src/services/runMetrics/api/types.ts deleted file mode 100644 index 97a59c2a22..0000000000 --- a/web/oss/src/services/runMetrics/api/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Aggregated statistics for a metric. -// Only a subset of these properties will be present depending on the metric type. -export interface BasicStats { - // Always present --------------------------------------------------------- - count: number - - // Numeric metrics ------------------------------------------------------- - sum?: number - mean?: number - min?: number - max?: number - range?: number - distribution?: {value: number; count: number}[] - percentiles?: Record - iqrs?: Record - binSize?: number - - // Categorical / binary metrics ----------------------------------------- - frequency?: {value: string | number | boolean | null; count: number}[] - unique?: (string | number | boolean | null)[] - rank?: {value: string | number | boolean | null; count: number}[] -} diff --git a/web/oss/src/state/entities/shared/README.md b/web/oss/src/state/entities/shared/README.md index 222f86c738..a610740597 100644 --- a/web/oss/src/state/entities/shared/README.md +++ b/web/oss/src/state/entities/shared/README.md @@ -938,7 +938,7 @@ export const testset = { ```typescript import {testset} from "@/state/entities/testset" -import {useInfiniteTablePagination} from "@/components/InfiniteVirtualTable" +import {useInfiniteTablePagination} from "@agenta/ui/table" const TestsetsTable = () => { // Use the paginated store with the table hook diff --git a/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts b/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts index 48716e0c72..2712e15405 100644 --- a/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts +++ b/web/oss/src/state/entities/shared/createPaginatedEntityStore.ts @@ -75,20 +75,17 @@ import type {Key} from "react" -import {atom} from "jotai" -import type {Atom, PrimitiveAtom, WritableAtom} from "jotai" -import {atomFamily} from "jotai/utils" - import { createSimpleTableStore, type BaseTableMeta, type SimpleTableStore, -} from "@/oss/components/InfiniteVirtualTable/helpers/createSimpleTableStore" -import type { - InfiniteTableFetchResult, - InfiniteTableRowBase, - WindowingState, -} from "@/oss/components/InfiniteVirtualTable/types" + type InfiniteTableFetchResult, + type InfiniteTableRowBase, + type WindowingState, +} from "@agenta/ui/table" +import {atom} from "jotai" +import type {Atom, PrimitiveAtom, WritableAtom} from "jotai" +import {atomFamily} from "jotai/utils" // ============================================================================ // TYPES diff --git a/web/oss/src/state/entities/testcase/paginatedStore.ts b/web/oss/src/state/entities/testcase/paginatedStore.ts index e8191a6686..19dcd7a5ba 100644 --- a/web/oss/src/state/entities/testcase/paginatedStore.ts +++ b/web/oss/src/state/entities/testcase/paginatedStore.ts @@ -24,14 +24,14 @@ * ``` */ -import {atom} from "jotai" - -import type {BaseTableMeta} from "@/oss/components/InfiniteVirtualTable/helpers/createSimpleTableStore" import type { + BaseTableMeta, InfiniteTableFetchResult, InfiniteTableRowBase, WindowingState, -} from "@/oss/components/InfiniteVirtualTable/types" +} from "@agenta/ui/table" +import {atom} from "jotai" + import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import {projectIdAtom} from "@/oss/state/project" diff --git a/web/oss/src/state/entities/testset/paginatedStore.ts b/web/oss/src/state/entities/testset/paginatedStore.ts index e4c3736d62..35469bc56d 100644 --- a/web/oss/src/state/entities/testset/paginatedStore.ts +++ b/web/oss/src/state/entities/testset/paginatedStore.ts @@ -21,14 +21,10 @@ * ``` */ +import type {BaseTableMeta, InfiniteTableFetchResult, InfiniteTableRowBase} from "@agenta/ui/table" import {atom, getDefaultStore, type Atom} from "jotai" import {atomWithStorage} from "jotai/vanilla/utils" -import type {BaseTableMeta} from "@/oss/components/InfiniteVirtualTable/helpers/createSimpleTableStore" -import type { - InfiniteTableFetchResult, - InfiniteTableRowBase, -} from "@/oss/components/InfiniteVirtualTable/types" import axios from "@/oss/lib/api/assets/axiosConfig" import {getAgentaApiUrl} from "@/oss/lib/helpers/api" import type {ExportFileType} from "@/oss/services/testsets/api" diff --git a/web/oss/src/state/evaluator/evaluatorDrawerStore.ts b/web/oss/src/state/evaluator/evaluatorDrawerStore.ts deleted file mode 100644 index 1772d30489..0000000000 --- a/web/oss/src/state/evaluator/evaluatorDrawerStore.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Evaluator Drawer Store — Compatibility Bridge - * - * Delegates to the unified WorkflowRevisionDrawer store. - * Maintains the old API surface so existing call sites don't need to change immediately. - */ - -import { - openWorkflowRevisionDrawerAtom, - closeWorkflowRevisionDrawerAtom, - workflowRevisionDrawerOpenAtom, - workflowRevisionDrawerEntityIdAtom, - workflowRevisionDrawerExpandedAtom, - workflowRevisionDrawerCallbackAtom, -} from "@agenta/playground-ui/workflow-revision-drawer" -import type { - DrawerInitialAppSelection, - WorkflowCreatedResult, -} from "@agenta/playground-ui/workflow-revision-drawer" -import {atom} from "jotai" - -// ================================================================ -// TYPES -// ================================================================ - -type EvaluatorDrawerMode = "create" | "view" - -interface OpenDrawerParams { - entityId: string - mode: EvaluatorDrawerMode - /** List of entity IDs for prev/next navigation */ - navigationIds?: string[] - /** @deprecated Use `onWorkflowCreated` to also receive the parent workflow id (`newAppId`). */ - onEvaluatorCreated?: (configId?: string) => void - /** Callback after successful evaluator creation/commit. Receives the new revision id (`configId`/`newRevisionId`) and the parent workflow id (`newAppId`). */ - onWorkflowCreated?: (result: WorkflowCreatedResult) => void - isolatedPlayground?: boolean - initialAppSelection?: DrawerInitialAppSelection - postCreateNavigation?: "default" | "stay" -} - -// ================================================================ -// RE-EXPORTS (read atoms — same underlying state) -// ================================================================ - -export const evaluatorDrawerEntityIdAtom = workflowRevisionDrawerEntityIdAtom -export const evaluatorDrawerOpenAtom = workflowRevisionDrawerOpenAtom -export const evaluatorDrawerExpandedAtom = workflowRevisionDrawerExpandedAtom -export const evaluatorDrawerCallbackAtom = workflowRevisionDrawerCallbackAtom - -// ================================================================ -// BRIDGE ACTIONS -// ================================================================ - -/** Open the drawer — maps evaluator mode to unified context */ -export const openEvaluatorDrawerAtom = atom(null, (_get, set, params: OpenDrawerParams) => { - set(openWorkflowRevisionDrawerAtom, { - entityId: params.entityId, - context: params.mode === "create" ? "evaluator-create" : "evaluator-view", - navigationIds: params.navigationIds, - onWorkflowCreated: params.onWorkflowCreated, - onEvaluatorCreated: params.onEvaluatorCreated, - isolatedPlayground: params.isolatedPlayground, - initialAppSelection: params.initialAppSelection, - postCreateNavigation: params.postCreateNavigation, - }) -}) - -/** Close the drawer */ -export const closeEvaluatorDrawerAtom = closeWorkflowRevisionDrawerAtom diff --git a/web/oss/src/state/url/focusDrawer.ts b/web/oss/src/state/url/focusDrawer.ts index 9bcfb3f5cb..92f1b6d986 100644 --- a/web/oss/src/state/url/focusDrawer.ts +++ b/web/oss/src/state/url/focusDrawer.ts @@ -1,12 +1,12 @@ -import {getDefaultStore} from "jotai" -import Router from "next/router" - import { openFocusDrawerAtom as openPreviewFocusDrawerAtom, focusDrawerAtom as previewFocusDrawerAtom, resetFocusDrawerAtom as resetPreviewFocusDrawerAtom, setFocusDrawerTargetAtom as setPreviewFocusDrawerTargetAtom, -} from "@/oss/components/EvalRunDetails/state/focusDrawerAtom" +} from "@agenta/evaluations-ui" +import {getDefaultStore} from "jotai" +import Router from "next/router" + import {navigationRequestAtom, type NavigationCommand} from "@/oss/state/appState" const isBrowser = typeof window !== "undefined" diff --git a/web/oss/src/styles/theme-variables.css b/web/oss/src/styles/theme-variables.css index e4125431ed..8cd6282f3d 100644 --- a/web/oss/src/styles/theme-variables.css +++ b/web/oss/src/styles/theme-variables.css @@ -488,9 +488,12 @@ } /* Run-comparison row tints. Light = the pale pastel per palette hue (unchanged); - dark = a low-alpha wash of the same hue so compare rows stay distinguishable - without rendering as bright light bands. Keep in sync with RUN_COMPARISON_PALETTE - (atoms/compare.ts). */ + dark = the same hue washed over the container bg. The dark wash MUST be opaque: it + backs sticky/fixed columns, and a translucent tint there lets cells scrolling + underneath bleed through (sticky column goes see-through on horizontal scroll). + `color-mix` composites the 14% hue over the live container token — the identical + wash the old `rgba(hue, 0.14)` produced over the standard cell bg, but fully opaque. + Keep in sync with RUN_COMPARISON_PALETTE (atoms/compare.ts). */ :root { --ag-cmp-tint-0: #eff6ff; --ag-cmp-tint-1: #fff7ed; @@ -499,11 +502,11 @@ --ag-cmp-tint-4: #fdf2f8; } .dark { - --ag-cmp-tint-0: rgba(59, 130, 246, 0.14); - --ag-cmp-tint-1: rgba(249, 115, 22, 0.14); - --ag-cmp-tint-2: rgba(139, 92, 246, 0.14); - --ag-cmp-tint-3: rgba(16, 185, 129, 0.14); - --ag-cmp-tint-4: rgba(236, 72, 153, 0.14); + --ag-cmp-tint-0: color-mix(in srgb, #3b82f6 14%, var(--ag-colorBgContainer)); + --ag-cmp-tint-1: color-mix(in srgb, #f97316 14%, var(--ag-colorBgContainer)); + --ag-cmp-tint-2: color-mix(in srgb, #8b5cf6 14%, var(--ag-colorBgContainer)); + --ag-cmp-tint-3: color-mix(in srgb, #10b981 14%, var(--ag-colorBgContainer)); + --ag-cmp-tint-4: color-mix(in srgb, #ec4899 14%, var(--ag-colorBgContainer)); } /* Arbitrary rgba() Tailwind classes the hex codemod couldn't reach (it only diff --git a/web/oss/tailwind.config.ts b/web/oss/tailwind.config.ts index 05d71112d0..550d39df94 100644 --- a/web/oss/tailwind.config.ts +++ b/web/oss/tailwind.config.ts @@ -129,6 +129,8 @@ export const createConfig = (content: string[] = []): Config => { "../packages/agenta-entities/src/**/*.{js,ts,jsx,tsx}", "../packages/agenta-playground/src/**/*.{js,ts,jsx,tsx}", "../packages/agenta-playground-ui/src/**/*.{js,ts,jsx,tsx}", + "../packages/agenta-evaluations/src/**/*.{js,ts,jsx,tsx}", + "../packages/agenta-evaluations-ui/src/**/*.{js,ts,jsx,tsx}", ...content, ], theme: { diff --git a/web/packages/agenta-annotation-ui/package.json b/web/packages/agenta-annotation-ui/package.json index 94c7375ccb..d29e9a451f 100644 --- a/web/packages/agenta-annotation-ui/package.json +++ b/web/packages/agenta-annotation-ui/package.json @@ -24,6 +24,7 @@ "@agenta/annotation": "workspace:../agenta-annotation", "@agenta/entities": "workspace:../agenta-entities", "@agenta/entity-ui": "workspace:../agenta-entity-ui", + "@agenta/evaluations-ui": "workspace:../agenta-evaluations-ui", "@agenta/shared": "workspace:../agenta-shared", "@agenta/ui": "workspace:../agenta-ui", "@phosphor-icons/react": "^2.1.10", diff --git a/web/packages/agenta-annotation-ui/src/components/AnnotationQueuesView/index.tsx b/web/packages/agenta-annotation-ui/src/components/AnnotationQueuesView/index.tsx index aed892cb14..72284f0017 100644 --- a/web/packages/agenta-annotation-ui/src/components/AnnotationQueuesView/index.tsx +++ b/web/packages/agenta-annotation-ui/src/components/AnnotationQueuesView/index.tsx @@ -1,4 +1,4 @@ -import {useMemo, useCallback, useEffect, useRef, useState} from "react" +import {useMemo, useCallback, useEffect, useState} from "react" import {userByIdFamily} from "@agenta/entities/shared" import { @@ -10,13 +10,9 @@ import { } from "@agenta/entities/simpleQueue" import type {SimpleQueueKind} from "@agenta/entities/simpleQueue" import {useEntityDelete} from "@agenta/entity-ui" +import {EvaluationListView, CreatedByCell, QueueProgressCell} from "@agenta/evaluations-ui" import {copyToClipboard} from "@agenta/ui" -import { - InfiniteVirtualTableFeatureShell, - useTableManager, - createStandardColumns, - FiltersPopoverTrigger, -} from "@agenta/ui/table" +import {createStandardColumns, FiltersPopoverTrigger} from "@agenta/ui/table" import {ArrowRight, Copy, PlusIcon, Trash} from "@phosphor-icons/react" import {Button, Divider, Input, Select, Tag, Typography} from "antd" import {useAtom, useAtomValue, useSetAtom} from "jotai" @@ -31,9 +27,6 @@ import { import CreateQueueDrawer from "../CreateQueueDrawer" import QueueStatusTag from "../QueueStatusTag" -import CreatedByCell from "./cells/CreatedByCell" -import QueueProgressCell from "./cells/QueueProgressCell" - const kindColorMap: Record = { traces: "blue", testcases: "green", @@ -249,7 +242,16 @@ const AnnotationQueuesView = ({ const {deleteEntity, deleteEntities} = useEntityDelete() const normalizedSearchTerm = searchTerm.trim() const hasSearchQuery = normalizedSearchTerm.length > 0 - const clearSelectionRef = useRef<() => void>(() => {}) + + const clearSelection = useCallback(() => { + // Selection state is store-backed (keyed by scopeId), so clearing it directly on + // the dataset store mirrors `useTableManager.clearSelection` without threading a + // callback through EvaluationListView. + getDefaultStore().set( + simpleQueuePaginatedStore.store.atoms.selectionAtom({scopeId: "annotation-queues"}), + [], + ) + }, []) const handleBulkDelete = useCallback( (records: SimpleQueueTableRow[]) => { @@ -261,12 +263,12 @@ const AnnotationQueuesView = ({ })), { onSuccess: () => { - clearSelectionRef.current() + clearSelection() }, }, ) }, - [deleteEntities], + [deleteEntities, clearSelection], ) const openCreateQueueDrawer = useCallback(() => { @@ -282,16 +284,6 @@ const AnnotationQueuesView = ({ [navigation], ) - const table = useTableManager({ - datasetStore: simpleQueuePaginatedStore.store as never, - scopeId: "annotation-queues", - pageSize: 50, - onRowClick: handleRowClick, - searchDeps: [normalizedSearchTerm, kindFilter], - onBulkDelete: handleBulkDelete, - }) - clearSelectionRef.current = table.clearSelection - const columns = useMemo( () => createStandardColumns([ @@ -484,13 +476,11 @@ const AnnotationQueuesView = ({ const tableProps = useMemo( () => ({ - ...(table.tableProps ?? {}), locale: { - ...(table.tableProps?.locale ?? {}), emptyText: emptyStateNode, }, }), - [table.tableProps, emptyStateNode], + [emptyStateNode], ) const exportOptions = useMemo( @@ -517,11 +507,20 @@ const AnnotationQueuesView = ({ return (
- - {...table.shellProps} + + // The dataset store is invariant in its ApiRow/Meta params, so the + // concrete SimpleQueue/SimpleQueueQueryMeta store does not assign to the + // `unknown`-parameterised prop. This mirrors the prior `as never` cast + // that fed `useTableManager` directly. + datasetStore={simpleQueuePaginatedStore.store as never} + scopeId="annotation-queues" + pageSize={50} columns={columns} filters={filtersNode} primaryActions={createButton} + onRowClick={handleRowClick} + onBulkDelete={handleBulkDelete} + searchDeps={[normalizedSearchTerm, kindFilter]} tableProps={tableProps} exportOptions={exportOptions} enableExport={canExportData} diff --git a/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ConfigurationView.tsx b/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ConfigurationView.tsx index fdc3706b69..c8c255c204 100644 --- a/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ConfigurationView.tsx +++ b/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ConfigurationView.tsx @@ -14,6 +14,7 @@ import {annotationSessionController} from "@agenta/annotation" import {simpleQueueMolecule} from "@agenta/entities/simpleQueue" import {resolveOutputSchema, resolveParameters, workflowMolecule} from "@agenta/entities/workflow" import {EntityDeleteModal} from "@agenta/entity-ui" +import {AssignmentsCell} from "@agenta/evaluations-ui" import {Editor} from "@agenta/ui/editor" import {SharedEditor} from "@agenta/ui/shared-editor" import {ArrowSquareOut, CaretDown} from "@phosphor-icons/react" @@ -21,7 +22,6 @@ import {Button, Form, Input, Segmented, Skeleton, Tag, Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" import {useAnnotationNavigation} from "../../context/AnnotationUIContext" -import AssignmentsCell from "../AnnotationQueuesView/cells/AssignmentsCell" const {Text} = Typography diff --git a/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx b/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx index 1143aeabc6..f303c8dae2 100644 --- a/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx +++ b/web/packages/agenta-annotation-ui/src/components/AnnotationSession/ScenarioListView.tsx @@ -17,7 +17,7 @@ import { OUTPUT_KEYS, } from "@agenta/annotation" import type {AnnotationColumnDef, ScenarioListColumnDef, SessionView} from "@agenta/annotation" -import type {EvaluationStatus} from "@agenta/entities/simpleQueue" +import type {SimpleQueueStatus} from "@agenta/entities/simpleQueue" import { traceEntityAtomFamily, traceRootSpanAtomFamily, @@ -1466,7 +1466,7 @@ const ScenarioListView = memo(function ScenarioListView({ ) const [searchTerm, setSearchTerm] = useState("") - const [statusFilter, setStatusFilter] = useState(null) + const [statusFilter, setStatusFilter] = useState(null) const handleAddToTestset = useCallback(() => { if (selectedScenarioIds.length > 0) { diff --git a/web/packages/agenta-annotation-ui/src/components/AnnotationStatusFilterSelect.tsx b/web/packages/agenta-annotation-ui/src/components/AnnotationStatusFilterSelect.tsx index 9aa0460ae0..9f8ae74393 100644 --- a/web/packages/agenta-annotation-ui/src/components/AnnotationStatusFilterSelect.tsx +++ b/web/packages/agenta-annotation-ui/src/components/AnnotationStatusFilterSelect.tsx @@ -1,7 +1,7 @@ -import type {EvaluationStatus} from "@agenta/entities/simpleQueue" +import type {SimpleQueueStatus} from "@agenta/entities/simpleQueue" import {Select} from "antd" -const STATUS_OPTIONS: {value: EvaluationStatus | ""; label: string}[] = [ +const STATUS_OPTIONS: {value: SimpleQueueStatus | ""; label: string}[] = [ {value: "", label: "All status"}, {value: "pending", label: "Pending"}, {value: "queued", label: "Queued"}, @@ -13,8 +13,8 @@ const STATUS_OPTIONS: {value: EvaluationStatus | ""; label: string}[] = [ ] interface AnnotationStatusFilterSelectProps { - value: EvaluationStatus | null - onChange: (value: EvaluationStatus | null) => void + value: SimpleQueueStatus | null + onChange: (value: SimpleQueueStatus | null) => void className?: string size?: "small" | "middle" | "large" popupMatchSelectWidth?: boolean | number @@ -31,7 +31,7 @@ const AnnotationStatusFilterSelect = ({