From cb13acf655e8eac8c206d1478f97d3b010ec6209 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Tue, 21 Jul 2026 23:45:45 -0700 Subject: [PATCH 01/43] feat(v2-ui): Perspective 4.5.2 deps, WASM bootstrap, Vite/vitest wiring --- .../__mocks__/perspective-bootstrap.js | 11 + .../v2/extractions/perspective-bootstrap.ts | 17 + extralit-frontend/nuxt.config.ts | 20 + extralit-frontend/package-lock.json | 420 +++++++++++++++++- extralit-frontend/package.json | 5 + extralit-frontend/vitest.config.ts | 2 + 6 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 extralit-frontend/__mocks__/perspective-bootstrap.js create mode 100644 extralit-frontend/components/v2/extractions/perspective-bootstrap.ts diff --git a/extralit-frontend/__mocks__/perspective-bootstrap.js b/extralit-frontend/__mocks__/perspective-bootstrap.js new file mode 100644 index 000000000..6fd46cbe6 --- /dev/null +++ b/extralit-frontend/__mocks__/perspective-bootstrap.js @@ -0,0 +1,11 @@ +// Perspective touches WASM + custom elements at import time; specs use this stub +// (same rationale as __mocks__/tabulator-tables.js). +export const initPerspective = async () => ({ + worker: async () => ({ + table: async (data) => ({ + __data: data, + size: async () => data.length, + delete: async () => undefined, + }), + }), +}); diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts new file mode 100644 index 000000000..da891904e --- /dev/null +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts @@ -0,0 +1,17 @@ +import perspective from "@perspective-dev/client"; +import perspective_viewer from "@perspective-dev/viewer"; +import "@perspective-dev/viewer-datagrid"; +import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url"; +import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url"; + +// SPA (ssr: false): this runs client-side only. Module-level guard so the WASM +// engines initialize exactly once no matter how often the page remounts (spec §3.3). +let ready: Promise | null = null; + +export const initPerspective = () => { + ready ??= Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), + ]).then(() => perspective); + return ready; +}; diff --git a/extralit-frontend/nuxt.config.ts b/extralit-frontend/nuxt.config.ts index afedbe052..c517b3a7b 100644 --- a/extralit-frontend/nuxt.config.ts +++ b/extralit-frontend/nuxt.config.ts @@ -85,6 +85,13 @@ export default defineNuxtConfig({ }, }, + vue: { + compilerOptions: { + // is a web Custom Element, not a Vue component (spec §3.3). + isCustomElement: (tag: string) => tag.startsWith("perspective-"), + }, + }, + vite: { plugins: [svgLoader()], // Allow reaching the dev server by arbitrary hostnames (e.g. a containerised @@ -95,6 +102,19 @@ export default defineNuxtConfig({ // a re-optimize + full page reload, which flakes open tabs and the e2e suite. optimizeDeps: { include: ["marked", "marked-highlight", "marked-katex-extension", "highlight.js", "dompurify"], + // WASM ESM packages break under esbuild pre-bundling; load them as-is. + exclude: [ + "@perspective-dev/client", + "@perspective-dev/server", + "@perspective-dev/viewer", + "@perspective-dev/viewer-datagrid", + ], + esbuildOptions: { + target: "esnext", + }, + }, + build: { + target: "esnext", // Perspective 4.x ESM/WASM requirement }, css: { preprocessorOptions: { diff --git a/extralit-frontend/package-lock.json b/extralit-frontend/package-lock.json index d01aacfb7..2c55eb211 100644 --- a/extralit-frontend/package-lock.json +++ b/extralit-frontend/package-lock.json @@ -12,6 +12,10 @@ "@codescouts/events": "^1.0.2", "@nuxtjs/i18n": "^10.4.0", "@orcid/bibtex-parse-js": "^0.0.25", + "@perspective-dev/client": "4.5.2", + "@perspective-dev/server": "4.5.2", + "@perspective-dev/viewer": "4.5.2", + "@perspective-dev/viewer-datagrid": "4.5.2", "@pinia/nuxt": "^0.11.3", "@sereneinserenade/tiptap-search-and-replace": "^0.1.1", "@tiptap/extension-document": "^2.4.0", @@ -56,6 +60,7 @@ "@nuxt/test-utils": "^4.0.3", "@playwright/test": "^1.61.1", "@types/papaparse": "^5.3.16", + "@types/react": "^19.2.17", "@types/tabulator-tables": "^6.2.0", "@typescript-eslint/eslint-plugin": "^8.62.0", "@typescript-eslint/parser": "^8.62.0", @@ -5371,6 +5376,45 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@perspective-dev/client": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@perspective-dev/client/-/client-4.5.2.tgz", + "integrity": "sha512-DWPHNPXiiZr5M3Y0L9a1hFxY2phB0iP4Oa3zjPfOrVC2OSvD5kh6SPVqNR4AeF19Y9J5Sowdv0lJUR2LQ/H8+w==", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/server": "", + "pro_self_extracting_wasm": "0.0.9", + "stoppable": "=1.1.0", + "ws": "^8.17.0" + } + }, + "node_modules/@perspective-dev/server": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@perspective-dev/server/-/server-4.5.2.tgz", + "integrity": "sha512-VlI+5yq0Cgw01/zylQqRVo32/wXAw7n4gVF8HEsr2oRzcAaRfpxCPwbUgzMkZFjgu7+TC+CUN1wIQ51lUkB79g==", + "license": "Apache-2.0" + }, + "node_modules/@perspective-dev/viewer": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@perspective-dev/viewer/-/viewer-4.5.2.tgz", + "integrity": "sha512-zFgrZt43sG0xyv2g6pI/35r32LoL4NN8N6yYjyyM/kXzmc9jNILIjfWj+4zZLyBshBE9xvWYBmfDXNjMfWf3vA==", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "", + "pro_self_extracting_wasm": "0.0.9" + } + }, + "node_modules/@perspective-dev/viewer-datagrid": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@perspective-dev/viewer-datagrid/-/viewer-datagrid-4.5.2.tgz", + "integrity": "sha512-PbeAZCdz+gjkfULU3QKFnLrQ0yH8kNi15WoPVy0zONZzTASKNyLhMNKshSFsjA9jBGwISC2RV8qU7TqTRE7O2Q==", + "license": "Apache-2.0", + "dependencies": { + "@perspective-dev/client": "", + "@perspective-dev/viewer": "", + "regular-table": "=0.8.4" + } + }, "node_modules/@pinia/nuxt": { "version": "0.11.3", "resolved": "https://registry.npmjs.org/@pinia/nuxt/-/nuxt-0.11.3.tgz", @@ -7029,6 +7073,16 @@ "@types/node": "*" } }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -8721,6 +8775,24 @@ "node": ">=6.0.0" } }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -8778,6 +8850,12 @@ "node": ">= 6" } }, + "node_modules/bn.js": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.5.tgz", + "integrity": "sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -8903,6 +8981,15 @@ "node": ">=4.0" } }, + "node_modules/buffer-pipe": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/buffer-pipe/-/buffer-pipe-0.0.3.tgz", + "integrity": "sha512-GlxfuD/NrKvCNs0Ut+7b1IHjylfdegMBxQIlZHj7bObKVQBxB5S84gtm2yu1mQ8/sSggceWBDPY0cPXgvX2MuA==", + "license": "MPL-2.0", + "dependencies": { + "safe-buffer": "^5.1.2" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -9066,7 +9153,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -9469,6 +9555,15 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -10764,6 +10859,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -11581,7 +11682,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11638,6 +11738,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/headers-polyfill": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-3.2.5.tgz", @@ -11660,6 +11769,18 @@ "integrity": "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -11687,6 +11808,59 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-server/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/http-shutdown": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz", @@ -13504,6 +13678,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leb128": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/leb128/-/leb128-0.0.5.tgz", + "integrity": "sha512-elbNtfmu3GndZbesVF6+iQAfVjOXW9bM/aax9WwMlABZW+oK9sbAZEXoewaPHmL34sxa8kVwWsru8cNE/yn2gg==", + "license": "MPL-2.0", + "dependencies": { + "bn.js": "^5.0.0", + "buffer-pipe": "0.0.3" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -14212,6 +14396,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -15699,6 +15892,18 @@ "node": ">=18" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -15863,6 +16068,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -16452,6 +16666,19 @@ "node": ">=4" } }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -17024,6 +17251,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pro_self_extracting_wasm": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/pro_self_extracting_wasm/-/pro_self_extracting_wasm-0.0.9.tgz", + "integrity": "sha512-95/dZfLmlGc/6Xp7gqvRBgXF8M+osw/Xtalz1U/Va8MpSC1TiR7rM4lEvAs1p/q4v/EZk6bow3tKEclbMGsSFQ==", + "license": "Apache-2.0", + "dependencies": { + "http-server": "^14.1.1", + "leb128": "^0.0.5", + "zx": "^8.6.1" + }, + "bin": { + "pro_self_extracting_wasm": "main.mjs" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -17316,6 +17557,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -17501,6 +17758,15 @@ "regexp-tree": "bin/regexp-tree" } }, + "node_modules/regular-table": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/regular-table/-/regular-table-0.8.4.tgz", + "integrity": "sha512-VmV3kxmkXX0BehXSFBeF6FMBqyYdciY6d/wI6/5hvXomH7K9nfGASOmitF3WeLsL9eiiattwR+o3tcu4En/Xaw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -17521,6 +17787,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -18076,7 +18348,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sass": { @@ -18171,6 +18442,12 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -18363,6 +18640,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -18552,6 +18901,16 @@ "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "license": "MIT" }, + "node_modules/stoppable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", + "license": "MIT", + "engines": { + "node": ">=4", + "npm": ">=6" + } + }, "node_modules/streamx": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", @@ -18812,7 +19171,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -19423,6 +19781,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/unplugin": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.2.0.tgz", @@ -19740,6 +20109,12 @@ "dev": true, "license": "MIT" }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -20665,6 +21040,31 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", @@ -21117,6 +21517,18 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zx": { + "version": "8.8.5", + "resolved": "https://registry.npmjs.org/zx/-/zx-8.8.5.tgz", + "integrity": "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA==", + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + } } } } diff --git a/extralit-frontend/package.json b/extralit-frontend/package.json index f0fa87f2a..1941aa546 100644 --- a/extralit-frontend/package.json +++ b/extralit-frontend/package.json @@ -29,6 +29,10 @@ "@codescouts/events": "^1.0.2", "@nuxtjs/i18n": "^10.4.0", "@orcid/bibtex-parse-js": "^0.0.25", + "@perspective-dev/client": "4.5.2", + "@perspective-dev/server": "4.5.2", + "@perspective-dev/viewer": "4.5.2", + "@perspective-dev/viewer-datagrid": "4.5.2", "@pinia/nuxt": "^0.11.3", "@sereneinserenade/tiptap-search-and-replace": "^0.1.1", "@tiptap/extension-document": "^2.4.0", @@ -73,6 +77,7 @@ "@nuxt/test-utils": "^4.0.3", "@playwright/test": "^1.61.1", "@types/papaparse": "^5.3.16", + "@types/react": "^19.2.17", "@types/tabulator-tables": "^6.2.0", "@typescript-eslint/eslint-plugin": "^8.62.0", "@typescript-eslint/parser": "^8.62.0", diff --git a/extralit-frontend/vitest.config.ts b/extralit-frontend/vitest.config.ts index de300c736..c72c6fa5f 100644 --- a/extralit-frontend/vitest.config.ts +++ b/extralit-frontend/vitest.config.ts @@ -8,6 +8,8 @@ export default defineVitestConfig({ // Mirror the old jest moduleNameMapper so non-Nuxt (happy-dom) specs resolve // the same import paths. Longer keys first so "~~"/"@@" win over "~"/"@". alias: { + // Perspective boots WASM at import; specs use the stub (see __mocks__/). + "~/components/v2/extractions/perspective-bootstrap": r("./__mocks__/perspective-bootstrap.js"), "~~": r("./"), "@@": r("./"), "~": r("./"), From 84f4ee0c71805672cf469839a2eab228eb0ca6c0 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 00:09:39 -0700 Subject: [PATCH 02/43] feat(v2-ui): ExtractionsGrid Perspective wrapper with banding and click plumbing --- .../ExtractionsGrid.client.test.ts | 49 ++++++ .../v2/extractions/ExtractionsGrid.client.vue | 158 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts create mode 100644 extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts new file mode 100644 index 000000000..65a5d137e --- /dev/null +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts @@ -0,0 +1,49 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import ExtractionsGrid from "./ExtractionsGrid.client.vue"; +import { WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; + +const tableSpy = vi.fn(async (data: unknown) => ({ __data: data, delete: async () => undefined })); +const initSpy = vi.fn(async () => ({ worker: async () => ({ table: tableSpy }) })); + +vi.mock("~/components/v2/extractions/perspective-bootstrap", () => ({ + initPerspective: (...args: unknown[]) => initSpy(...args), +})); + +const PROJECTION = new WorkspaceProjection( + [ + { + name: "Design.type", + schemaId: "s-1", + schemaName: "Design", + questionName: "type", + subColumn: null, + dtype: "text", + }, + ], + [ + { + reference: "10.1/a", + rowIndex: 0, + cells: { + "Design.type": { value: "RCT", source: "response", recordId: "r-1", agent: null, score: null }, + }, + }, + ], + 1 +); + +describe("ExtractionsGrid", () => { + it("boots perspective once and loads the flat projection rows into a table", async () => { + mount(ExtractionsGrid, { + props: { projection: PROJECTION }, + global: { + config: { compilerOptions: { isCustomElement: (tag: string) => tag.startsWith("perspective-") } }, + }, + }); + await flushPromises(); + + expect(initSpy).toHaveBeenCalledTimes(1); + expect(tableSpy).toHaveBeenCalledWith([{ reference: "10.1/a", "Design.type": "RCT" }]); + }); +}); diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue new file mode 100644 index 000000000..529c699d4 --- /dev/null +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -0,0 +1,158 @@ + + + + + From 7ead2231c06dad733370ab3d9a85d41537755d6e Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 00:13:16 -0700 Subject: [PATCH 03/43] fix(v2-ui): type the ExtractionsGrid test bootstrap spy --- .../components/v2/extractions/ExtractionsGrid.client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts index 65a5d137e..989ee4d8f 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts @@ -7,7 +7,7 @@ const tableSpy = vi.fn(async (data: unknown) => ({ __data: data, delete: async ( const initSpy = vi.fn(async () => ({ worker: async () => ({ table: tableSpy }) })); vi.mock("~/components/v2/extractions/perspective-bootstrap", () => ({ - initPerspective: (...args: unknown[]) => initSpy(...args), + initPerspective: () => initSpy(), })); const PROJECTION = new WorkspaceProjection( From 8efee73ad21a70908bd3878fbca6f63adbaf0a41 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 00:28:31 -0700 Subject: [PATCH 04/43] fix(v2-ui): guard ExtractionsGrid cell type, unmount race, and projection changes --- .../v2/extractions/ExtractionsGrid.client.vue | 177 +++++++++++++++--- 1 file changed, 146 insertions(+), 31 deletions(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 529c699d4..0154e1d4c 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -3,7 +3,7 @@ From 3c5e68aaac1794a28606d755a1d046da555f136a Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 00:48:27 -0700 Subject: [PATCH 05/43] fix(v2-ui): serialize ExtractionsGrid table loads and surface swap-delete failures --- .../v2/extractions/ExtractionsGrid.client.vue | 103 ++++++++++++++---- 1 file changed, 84 insertions(+), 19 deletions(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 0154e1d4c..14036b359 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -69,6 +69,12 @@ let unsubscribeStyle: (() => void) | null = null; // unhandled rejection. let cancelled = false; +// Every `loadProjectionIntoViewer` call is appended to this chain instead of running +// immediately: see the doc comment above that function for why unserialized calls (mount +// racing a prop-change watcher, or watcher racing watcher) can clobber each other and leak +// tables. +let loadChain: Promise = Promise.resolve(); + async function safeDelete(candidate: PerspectiveTableLike | null): Promise { if (!candidate) { return; @@ -81,6 +87,24 @@ async function safeDelete(candidate: PerspectiveTableLike | null): Promise } } +async function deleteSupersededTable(candidate: PerspectiveTableLike | null): Promise { + if (!candidate) { + return; + } + try { + await candidate.delete(); + } catch (error) { + // Neither `load()` nor `eject()` documents synchronously releasing the View bound to + // the table being replaced (see the doc comment above `loadProjectionIntoViewer`), so + // this can legitimately still throw per `Table.delete()`'s documented precondition + // ("no View instances registered to it, which must be deleted first" — + // `@perspective-dev/client/dist/wasm/perspective-js.d.ts`). Swallowing it here would + // hide a leaked Table + View on every projection swap, so surface it loudly instead — + // the grid itself keeps working either way. + console.warn("[ExtractionsGrid] failed to delete the superseded Perspective table after a projection swap", error); + } +} + function cellMetaAt(rt: RegularTableLike, td: HTMLElement): { rowIndex: number; columnName: string } | null { const meta = rt.getMeta(td); // regular-table uses a single DOM interface (HTMLTableCellElement) for both and @@ -139,22 +163,49 @@ function handleClick(event: Event): void { /** * Builds a fresh Perspective table for `projection` and installs it into the viewer, - * tearing down `previousTable` (the table this call is replacing, if any) once it is safe - * to do so. + * tearing down the table it replaces (if any) once it is safe to do so. + * + * Concurrency: both `onMounted` and the `watch(() => props.projection, ...)` callback below + * funnel through this function, and Vue does not serialize async watcher callbacks against + * each other or against `onMounted` — two calls can be in flight simultaneously (mount vs. + * watch, or watch vs. a second watch firing before the first's async body has finished). + * Left unserialized, a slower call resolving after a faster, more-recently-requested one + * would clobber the correct, just-rendered table with a stale one, and whichever table lost + * that race would never become anyone's `previousTable` — a leaked Table + View. To prevent + * this, every call is appended to `loadChain`: a new call's body does not start running + * until every call requested before it has fully settled (finished a swap, or bailed out). + * This guarantees calls run in request order, so the table for the most-recently-requested + * `projection` is always the last thing written to the viewer, exactly one Table is ever + * live, and each call can safely read module-scope `table` — "whatever the viewer is + * currently bound to" — without needing it passed as a parameter. * - * Ordering: `viewer.load(newTable)` is documented (see - * `@perspective-dev/viewer/dist/wasm/perspective-viewer.d.ts`, `load()`) as equivalent to - * `restore({ table: newTable.get_name() })` and resolving only once "the first frame ... - * is guaranteed to have been drawn" — i.e. by the time it resolves the viewer is bound to - * `newTable` and no longer references `previousTable`. That makes `previousTable.delete()` - * safe immediately after `load()` (and the `restore()` that reasserts the Datagrid plugin - * config) resolve, without ever calling `viewer.delete()` — unlike full unmount, this path - * replaces the bound table on a *live* viewer, it does not tear the viewer down. + * Table release ordering on a swap: neither `load()` nor `eject()` (see their docstrings in + * `@perspective-dev/viewer/dist/wasm/perspective-viewer.d.ts`) documents synchronously + * dropping the View bound to the table being replaced. `load()` only guarantees "the first + * frame ... is guaranteed to have been drawn" — nothing about releasing a *previous* + * binding. `Table.delete()` (`@perspective-dev/client/dist/wasm/perspective-js.d.ts`) + * throws unless the table "has no View instances registered to it (which must be deleted + * first)". `eject()`, however, is documented to "Restart this `` to its + * initial state, before `load()`" specifically so `load()` can be called again on the same + * element — the strongest documented lever available for releasing the current binding + * without tearing the viewer down — so it is called on the outgoing table's behalf before + * loading its replacement. That still isn't an explicit guarantee against `Table.delete()` + * throwing afterwards, so a rejection there is handled by `deleteSupersededTable`, not + * silently swallowed. */ -async function loadProjectionIntoViewer( - projection: WorkspaceProjection, - previousTable: PerspectiveTableLike | null -): Promise { +function loadProjectionIntoViewer(projection: WorkspaceProjection): Promise { + const run = loadChain.then(() => performLoad(projection)); + // Keep the chain usable even if this run rejects or the viewer bails out — a later, + // already-requested call must not be stuck behind it forever. Callers that care about + // this specific call's outcome still get it via the returned `run`. + loadChain = run.then( + () => undefined, + () => undefined + ); + return run; +} + +async function performLoad(projection: WorkspaceProjection): Promise { if (!client) { return; } @@ -165,6 +216,11 @@ async function loadProjectionIntoViewer( await safeDelete(newTable); return; } + + // Safe to read fresh here: by construction (see the doc comment above + // `loadProjectionIntoViewer`) no other call is running concurrently, so this is exactly + // the table the viewer is currently bound to (or `null` on the very first load). + const previousTable = table; table = newTable; const viewer = viewerEl.value; @@ -175,6 +231,17 @@ async function loadProjectionIntoViewer( } try { + if (previousTable && viewer.eject) { + // Release the viewer's current View/Table binding before loading the replacement — + // see the doc comment above `loadProjectionIntoViewer` for why this, and not just + // `load()`, is what makes the delete below safe. + await viewer.eject(); + if (cancelled) { + await safeDelete(previousTable); + return; + } + } + await viewer.load(newTable); if (cancelled) { await safeDelete(previousTable); @@ -187,8 +254,7 @@ async function loadProjectionIntoViewer( return; } - // The viewer is now bound to `newTable`; `previousTable` (if any) is unreferenced. - await safeDelete(previousTable); + await deleteSupersededTable(previousTable); unsubscribeStyle?.(); unsubscribeStyle = null; @@ -222,7 +288,7 @@ onMounted(async () => { } // Read `props.projection` fresh (not a captured value) in case it changed while the // above awaited. - await loadProjectionIntoViewer(props.projection, null); + await loadProjectionIntoViewer(props.projection); }); watch( @@ -232,8 +298,7 @@ watch( return; } bandByRow = bandParity(nextProjection); - const previousTable = table; - await loadProjectionIntoViewer(nextProjection, previousTable); + await loadProjectionIntoViewer(nextProjection); } ); From bc6b7e9085600df8f8b82984676cbdbd5de72e3c Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 01:00:36 -0700 Subject: [PATCH 06/43] fix(v2-ui): delete the superseded ExtractionsGrid table on every unwind path --- .../v2/extractions/ExtractionsGrid.client.vue | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 14036b359..c929a4f47 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -222,6 +222,11 @@ async function performLoad(projection: WorkspaceProjection): Promise { // the table the viewer is currently bound to (or `null` on the very first load). const previousTable = table; table = newTable; + // Tracks whether `previousTable` has already been handed to a delete call on one of the + // `cancelled` early-return paths below, so the catch block (which can be reached after any + // of those same awaits throws instead of merely observing `cancelled`) never deletes it a + // second time. + let previousTableDeleted = false; const viewer = viewerEl.value; if (!viewer?.load) { @@ -238,6 +243,7 @@ async function performLoad(projection: WorkspaceProjection): Promise { await viewer.eject(); if (cancelled) { await safeDelete(previousTable); + previousTableDeleted = true; return; } } @@ -245,16 +251,19 @@ async function performLoad(projection: WorkspaceProjection): Promise { await viewer.load(newTable); if (cancelled) { await safeDelete(previousTable); + previousTableDeleted = true; return; } // Static grid: no config/settings panel, natural (insertion) order — no `sort`. await viewer.restore({ plugin: "Datagrid", settings: false }); if (cancelled) { await safeDelete(previousTable); + previousTableDeleted = true; return; } await deleteSupersededTable(previousTable); + previousTableDeleted = true; unsubscribeStyle?.(); unsubscribeStyle = null; @@ -273,7 +282,12 @@ async function performLoad(projection: WorkspaceProjection): Promise { } catch { // A viewer detached mid-flight (fast route navigation, or torn down by onBeforeUnmount // while this chain was still in progress) throws here — not actionable, and must not - // escape as an unhandled rejection. + // escape as an unhandled rejection. `previousTable`, however, is still this call's + // responsibility: none of the `cancelled` branches above ran (they return instead of + // throwing), so unless the normal swap already deleted it, it would otherwise leak. + if (previousTable && !previousTableDeleted) { + await deleteSupersededTable(previousTable); + } } } From 66f7e4bbf2b1aeb6ae15bd67f6bb66680af734a7 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 01:12:36 -0700 Subject: [PATCH 07/43] feat(v2-ui): /extractions page with Perspective grid and workspace override --- .../composables/useV2Breadcrumbs.ts | 13 +++- extralit-frontend/pages/extractions/index.vue | 62 +++++++++++++++++++ .../useExtractionsViewModel.test.ts | 49 +++++++++++++++ .../extractions/useExtractionsViewModel.ts | 47 ++++++++++++++ extralit-frontend/translation/de.js | 7 +++ extralit-frontend/translation/en.js | 7 +++ extralit-frontend/translation/es.js | 7 +++ extralit-frontend/translation/ja.js | 7 +++ 8 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 extralit-frontend/pages/extractions/index.vue create mode 100644 extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts create mode 100644 extralit-frontend/pages/extractions/useExtractionsViewModel.ts diff --git a/extralit-frontend/composables/useV2Breadcrumbs.ts b/extralit-frontend/composables/useV2Breadcrumbs.ts index 1352d55df..3fdd7c6b6 100644 --- a/extralit-frontend/composables/useV2Breadcrumbs.ts +++ b/extralit-frontend/composables/useV2Breadcrumbs.ts @@ -15,5 +15,16 @@ export const useV2Breadcrumbs = () => { return crumbs; }; - return { schemasBreadcrumbs }; + const extractionsBreadcrumbs = (leaf: { name: string; link?: string }[] = []): BreadcrumbItem[] => { + const selected = workspacesStore.get().selectedWorkspace; + const crumbs: BreadcrumbItem[] = [{ name: "Home", link: "/" }]; + if (selected) { + crumbs.push({ name: selected.name, isWorkspace: true, workspaceId: selected.id }); + } + crumbs.push({ name: "Extractions", link: "/extractions" }); + crumbs.push(...leaf); + return crumbs; + }; + + return { schemasBreadcrumbs, extractionsBreadcrumbs }; }; diff --git a/extralit-frontend/pages/extractions/index.vue b/extralit-frontend/pages/extractions/index.vue new file mode 100644 index 000000000..717850730 --- /dev/null +++ b/extralit-frontend/pages/extractions/index.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts new file mode 100644 index 000000000..6512943d8 --- /dev/null +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -0,0 +1,49 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; + +const executeMock = vi.fn(); +vi.mock("ts-injecty", () => ({ + useResolve: () => ({ execute: executeMock }), +})); + +import { useExtractionsViewModel } from "./useExtractionsViewModel"; + +describe("useExtractionsViewModel", () => { + beforeEach(() => { + setActivePinia(createPinia()); + executeMock.mockReset(); + }); + + it("loads the projection for the override workspace id", async () => { + const projection = new WorkspaceProjection([], [], 0); + executeMock.mockResolvedValue(projection); + + const vm = useExtractionsViewModel("w-1"); + await vm.load(); + + expect(executeMock).toHaveBeenCalledWith("w-1"); + expect(vm.projection.value).toBe(projection); + expect(vm.loadFailed.value).toBe(false); + expect(vm.isLoading.value).toBe(false); + }); + + it("does nothing without a workspace id", async () => { + const vm = useExtractionsViewModel(null); + await vm.load(); + expect(executeMock).not.toHaveBeenCalled(); + }); + + it("flags load failure", async () => { + executeMock.mockRejectedValue(new Error("boom")); + const vm = useExtractionsViewModel("w-1"); + await vm.load(); + expect(vm.loadFailed.value).toBe(true); + }); + + it("builds the annotation URL on cell click but does not navigate (guard off)", () => { + const vm = useExtractionsViewModel("w-1"); + const url = vm.onCellClick({ schemaId: "s-1", reference: "10.1/a b" }); + expect(url).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); + }); +}); diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts new file mode 100644 index 000000000..6dd45cc05 --- /dev/null +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -0,0 +1,47 @@ +import { computed, ref, shallowRef, watch } from "vue"; +import { useResolve } from "ts-injecty"; +import { GetWorkspaceProjectionUseCase } from "~/v2/domain/usecases/get-workspace-projection-use-case"; +import { type WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; +import { buildAnnotationUrl, ANNOTATION_CELL_LINKS_ENABLED } from "~/v2/domain/entities/projection/grid-adapter"; +// Documented v1 exception: workspace selection survives Phase 6 (see plan Global Constraints). +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; + +export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => { + const getWorkspaceProjectionUseCase = useResolve(GetWorkspaceProjectionUseCase); + const workspacesStore = useWorkspaces(); + + // shallowRef: preserves the exact WorkspaceProjection instance the use-case returns + // (avoids Vue wrapping it in a reactive Proxy) — the grid's `bandParity`/`toPerspectiveData` + // adapters and view-model identity assertions both rely on referential equality. + const projection = shallowRef(null); + const isLoading = ref(false); + const loadFailed = ref(false); + + const workspaceId = computed(() => workspaceIdOverride ?? workspacesStore.get().selectedWorkspace?.id ?? null); + + const load = async () => { + if (!workspaceId.value) return; + isLoading.value = true; + loadFailed.value = false; + try { + projection.value = await getWorkspaceProjectionUseCase.execute(workspaceId.value); + } catch { + loadFailed.value = true; // AxiosErrorHandler already notified + } finally { + isLoading.value = false; + } + }; + + watch(workspaceId, load); + + const onCellClick = ({ schemaId, reference }: { schemaId: string; reference: string }): string => { + const url = buildAnnotationUrl(schemaId, reference); + if (ANNOTATION_CELL_LINKS_ENABLED) { + // Guarded off: annotation-mode cannot resolve v2 schema ids yet (see grid-adapter.ts). + window.location.href = url; + } + return url; + }; + + return { projection, isLoading, loadFailed, workspaceId, load, onCellClick }; +}; diff --git a/extralit-frontend/translation/de.js b/extralit-frontend/translation/de.js index 5d9084440..997df59a0 100644 --- a/extralit-frontend/translation/de.js +++ b/extralit-frontend/translation/de.js @@ -111,6 +111,13 @@ export default { docs: "Dokumentation ansehen", logout: "Abmelden", }, + extractions: { + title: "Extractions", + loading: "Loading extractions…", + empty: "No extracted references in this workspace yet.", + noWorkspace: "Select a workspace to view its extraction table.", + loadError: "Could not load the extraction table.", + }, settings: { title: "Datensatz-Einstellungen", datasetInfo: "Datensatz-Informationen", diff --git a/extralit-frontend/translation/en.js b/extralit-frontend/translation/en.js index bc540ea5e..1b021395c 100644 --- a/extralit-frontend/translation/en.js +++ b/extralit-frontend/translation/en.js @@ -133,6 +133,13 @@ export default { rebuildIndexHint: "Recovers search after a failed sync. May take tens of seconds.", rebuildIndexDone: "Re-indexed {count} records", }, + extractions: { + title: "Extractions", + loading: "Loading extractions…", + empty: "No extracted references in this workspace yet.", + noWorkspace: "Select a workspace to view its extraction table.", + loadError: "Could not load the extraction table.", + }, review: { title: "Review", submit: "Submit", diff --git a/extralit-frontend/translation/es.js b/extralit-frontend/translation/es.js index edbfefea0..be8a4a7a6 100644 --- a/extralit-frontend/translation/es.js +++ b/extralit-frontend/translation/es.js @@ -108,6 +108,13 @@ export default { docs: "Documentación", logout: "Cerrar sesión", }, + extractions: { + title: "Extractions", + loading: "Loading extractions…", + empty: "No extracted references in this workspace yet.", + noWorkspace: "Select a workspace to view its extraction table.", + loadError: "Could not load the extraction table.", + }, settings: { title: "Configuración del dataset", datasetInfo: "Información del dataset", diff --git a/extralit-frontend/translation/ja.js b/extralit-frontend/translation/ja.js index 90186145c..4b6fdd3e4 100644 --- a/extralit-frontend/translation/ja.js +++ b/extralit-frontend/translation/ja.js @@ -115,6 +115,13 @@ export default { docs: "ドキュメンテーションを見る", logout: "ログアウト", }, + extractions: { + title: "Extractions", + loading: "Loading extractions…", + empty: "No extracted references in this workspace yet.", + noWorkspace: "Select a workspace to view its extraction table.", + loadError: "Could not load the extraction table.", + }, settings: { title: "データセット設定", datasetInfo: "データセット情報", From 194b20d3f5762b3c2c1fff96c15defbdfd5fc333 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 01:25:41 -0700 Subject: [PATCH 08/43] fix(v2-ui): make extractions view-model loads race-safe --- .../useExtractionsViewModel.test.ts | 87 +++++++++++++++++++ .../extractions/useExtractionsViewModel.ts | 48 ++++++++-- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts index 6512943d8..3767b6de2 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -1,6 +1,8 @@ import { createPinia, setActivePinia } from "pinia"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; +import { Workspace } from "~/v1/domain/entities/workspace/Workspace"; +import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; const executeMock = vi.fn(); vi.mock("ts-injecty", () => ({ @@ -9,6 +11,18 @@ vi.mock("ts-injecty", () => ({ import { useExtractionsViewModel } from "./useExtractionsViewModel"; +// Deferred promise helper — lets tests control settlement order deterministically instead of +// relying on timers or `setTimeout` scheduling. +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + describe("useExtractionsViewModel", () => { beforeEach(() => { setActivePinia(createPinia()); @@ -46,4 +60,77 @@ describe("useExtractionsViewModel", () => { const url = vm.onCellClick({ schemaId: "s-1", reference: "10.1/a b" }); expect(url).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); }); + + it("keeps the last-requested workspace's result when a superseded slow response resolves later", async () => { + const firstCall = deferred(); + const secondCall = deferred(); + executeMock.mockImplementation((id: string) => (id === "w-1" ? firstCall.promise : secondCall.promise)); + + const { saveSelectedWorkspace } = useWorkspaces(); + saveSelectedWorkspace(new Workspace("w-1", "Workspace 1")); + + const vm = useExtractionsViewModel(); + const load1 = vm.load(); + + saveSelectedWorkspace(new Workspace("w-2", "Workspace 2")); + const load2 = vm.load(); + + // Second (newer) request resolves first. + const projectionW2 = new WorkspaceProjection([], [], 2); + secondCall.resolve(projectionW2); + await load2; + + // First (superseded) request resolves last — must not clobber the newer result. + const projectionW1 = new WorkspaceProjection([], [], 1); + firstCall.resolve(projectionW1); + await load1; + + expect(executeMock).toHaveBeenCalledTimes(2); + expect(vm.projection.value).toBe(projectionW2); + expect(vm.isLoading.value).toBe(false); + expect(vm.loadFailed.value).toBe(false); + }); + + it("does not mark loadFailed when a superseded request rejects after a newer request already succeeded", async () => { + const firstCall = deferred(); + const secondCall = deferred(); + executeMock.mockImplementation((id: string) => (id === "w-1" ? firstCall.promise : secondCall.promise)); + + const { saveSelectedWorkspace } = useWorkspaces(); + saveSelectedWorkspace(new Workspace("w-1", "Workspace 1")); + + const vm = useExtractionsViewModel(); + const load1 = vm.load(); + + saveSelectedWorkspace(new Workspace("w-2", "Workspace 2")); + const load2 = vm.load(); + + const projectionW2 = new WorkspaceProjection([], [], 0); + secondCall.resolve(projectionW2); + await load2; + + firstCall.reject(new Error("boom")); + await load1; + + expect(vm.loadFailed.value).toBe(false); + expect(vm.projection.value).toBe(projectionW2); + }); + + it("dedupes concurrent loads for the same workspace id into a single execute call", async () => { + const projection = new WorkspaceProjection([], [], 0); + const pending = deferred(); + executeMock.mockReturnValue(pending.promise); + + const vm = useExtractionsViewModel("w-1"); + const load1 = vm.load(); + const load2 = vm.load(); + + pending.resolve(projection); + await Promise.all([load1, load2]); + + expect(executeMock).toHaveBeenCalledTimes(1); + expect(vm.projection.value).toBe(projection); + expect(vm.isLoading.value).toBe(false); + expect(vm.loadFailed.value).toBe(false); + }); }); diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts index 6dd45cc05..f7b27a112 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -19,17 +19,47 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => const workspaceId = computed(() => workspaceIdOverride ?? workspacesStore.get().selectedWorkspace?.id ?? null); - const load = async () => { - if (!workspaceId.value) return; + // Race-safety: `load()` can be re-entered concurrently — e.g. `ensureWorkspaces()` flips + // `workspaceId` from null to a real id mid-flight, firing `watch(workspaceId, load)` before + // the page's own `await load()` continuation resumes, or the user switches workspace while a + // load is still in flight. `requestToken` lets a stale in-flight call detect it has been + // superseded before it writes to shared state; `inFlight` dedupes concurrent calls for the + // same workspace id into a single `execute()` call. + let requestToken = 0; + let inFlight: { workspaceId: string; promise: Promise } | null = null; + + const load = (): Promise => { + const id = workspaceId.value; + if (!id) return Promise.resolve(); + + if (inFlight && inFlight.workspaceId === id) { + return inFlight.promise; + } + + const token = ++requestToken; isLoading.value = true; loadFailed.value = false; - try { - projection.value = await getWorkspaceProjectionUseCase.execute(workspaceId.value); - } catch { - loadFailed.value = true; // AxiosErrorHandler already notified - } finally { - isLoading.value = false; - } + + const promise = (async () => { + try { + const result = await getWorkspaceProjectionUseCase.execute(id); + if (token !== requestToken) return; // superseded by a newer load + projection.value = result; + } catch { + if (token !== requestToken) return; // superseded by a newer load + loadFailed.value = true; // AxiosErrorHandler already notified + } finally { + if (token === requestToken) { + isLoading.value = false; + } + if (inFlight?.workspaceId === id) { + inFlight = null; + } + } + })(); + + inFlight = { workspaceId: id, promise }; + return promise; }; watch(workspaceId, load); From b57455c80b4f2e573f4ac110fc720808c8a4e6eb Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 01:36:49 -0700 Subject: [PATCH 09/43] fix(v2-ui): key extractions in-flight dedup on entry identity --- .../useExtractionsViewModel.test.ts | 60 +++++++++++++++++++ .../extractions/useExtractionsViewModel.ts | 9 ++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts index 3767b6de2..877b53c64 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -133,4 +133,64 @@ describe("useExtractionsViewModel", () => { expect(vm.isLoading.value).toBe(false); expect(vm.loadFailed.value).toBe(false); }); + + it("dedupes a w-1 load that ping-pongs back (w-1 -> w-2 -> w-1 -> w-1) against the still-pending later w-1 request", async () => { + // Reproduces: oldest w-1 call's `finally` must not clear a newer generation's `inFlight` + // entry just because it shares the same workspace id. Sequence: w-1 (gen A) -> w-2 + // (evicts gen A's entry) -> w-1 (gen B, a genuinely new request since gen A's entry is + // gone) -> gen A settles -> a 4th w-1 load must dedupe against gen B, still pending. + const w1GenA = deferred(); + const w2Call = deferred(); + const w1GenB = deferred(); + const w1Calls = [w1GenA, w1GenB]; + // If the dedup defect lets a redundant w-1 call through, resolve it immediately (rather + // than with a never-settled deferred) so the test fails on the call-count assertion + // instead of hanging on an unresolved `await`. + const unexpectedExtraCall = new WorkspaceProjection([], [], -1); + executeMock.mockImplementation((id: string) => { + if (id !== "w-1") return w2Call.promise; + const next = w1Calls.shift(); + return next ? next.promise : Promise.resolve(unexpectedExtraCall); + }); + + const { saveSelectedWorkspace } = useWorkspaces(); + saveSelectedWorkspace(new Workspace("w-1", "Workspace 1")); + + const vm = useExtractionsViewModel(); + const load1 = vm.load(); // w-1 gen A — creates inFlight{w-1, gen A} + + saveSelectedWorkspace(new Workspace("w-2", "Workspace 2")); + const load2 = vm.load(); // w-2 — id mismatch, evicts gen A's entry, inFlight{w-2} + + saveSelectedWorkspace(new Workspace("w-1", "Workspace 1")); + const load3 = vm.load(); // w-1 gen B — id mismatch against inFlight{w-2}, a real 3rd call + + expect(executeMock).toHaveBeenCalledTimes(3); + + // Oldest w-1 call (gen A) settles while gen B is still in flight. Buggy code clears + // `inFlight` here because it only compares `workspaceId === 'w-1'`, which also matches + // gen B's still-pending entry. + const projectionGenA = new WorkspaceProjection([], [], 1); + w1GenA.resolve(projectionGenA); + await load1; + + // A 4th w-1 load must dedupe against gen B (still pending), not fire a redundant call. + const load4 = vm.load(); + expect(executeMock).toHaveBeenCalledTimes(3); + + const projectionGenB = new WorkspaceProjection([], [], 2); + w1GenB.resolve(projectionGenB); + await load3; + await load4; + + const projectionW2 = new WorkspaceProjection([], [], 0); + w2Call.resolve(projectionW2); + await load2; + + // gen B is the last-requested (highest token) call, so its result wins. + expect(executeMock).toHaveBeenCalledTimes(3); + expect(vm.projection.value).toBe(projectionGenB); + expect(vm.isLoading.value).toBe(false); + expect(vm.loadFailed.value).toBe(false); + }); }); diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts index f7b27a112..bb2018e54 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -26,7 +26,7 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => // superseded before it writes to shared state; `inFlight` dedupes concurrent calls for the // same workspace id into a single `execute()` call. let requestToken = 0; - let inFlight: { workspaceId: string; promise: Promise } | null = null; + let inFlight: { workspaceId: string; token: number; promise: Promise } | null = null; const load = (): Promise => { const id = workspaceId.value; @@ -52,13 +52,16 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => if (token === requestToken) { isLoading.value = false; } - if (inFlight?.workspaceId === id) { + // Only the call that created the current `inFlight` entry may clear it — a stale + // ping-pong (w-1 -> w-2 -> w-1) can share `workspaceId` with a newer generation's + // entry, so identity is keyed on `token`, not the string id. + if (inFlight?.token === token) { inFlight = null; } } })(); - inFlight = { workspaceId: id, promise }; + inFlight = { workspaceId: id, token, promise }; return promise; }; From 293466ae8543a1d4c305437d432e0e9fa57bb2be Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 01:47:42 -0700 Subject: [PATCH 10/43] =?UTF-8?q?refactor(v2-ui):=20delete=20reference-rev?= =?UTF-8?q?iew=20page=20=E2=80=94=20review=20derives=20from=20projection?= =?UTF-8?q?=20(spec=20=C2=A73.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v2/review/ProjectionReviewForm.test.ts | 96 ------- .../v2/review/ProjectionReviewForm.vue | 32 --- .../v2/review/ReviewCellInput.test.ts | 2 +- .../components/v2/review/ReviewCellInput.vue | 2 +- .../components/v2/review/ReviewProvenance.vue | 2 +- .../components/v2/review/ReviewRecordCard.vue | 137 ---------- .../components/v2/schemas/V2RecordsTable.vue | 7 +- .../e2e/v2/draft-lifecycle.spec.ts | 34 --- extralit-frontend/e2e/v2/review-loop.spec.ts | 33 --- .../e2e/v2/slashed-reference.spec.ts | 23 -- .../pages/references/[...reference].vue | 69 ----- .../useReferenceReviewViewModel.test.ts | 75 ------ .../references/useReferenceReviewViewModel.ts | 60 ----- extralit-frontend/translation/en.js | 12 - extralit-frontend/v2/di/di.ts | 11 - .../domain/entities/review/ReferenceReview.ts | 65 ----- .../v2/domain/entities/review/ReviewCell.ts | 18 ++ .../entities/review/widget-adapters.test.ts | 2 +- .../domain/entities/review/widget-adapters.ts | 2 +- .../get-reference-review-use-case.test.ts | 245 ------------------ .../usecases/get-reference-review-use-case.ts | 131 ---------- .../get-workspace-projection-use-case.ts | 2 +- .../storage/ReferenceReviewsStorage.ts | 27 -- 23 files changed, 25 insertions(+), 1062 deletions(-) delete mode 100644 extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts delete mode 100644 extralit-frontend/components/v2/review/ProjectionReviewForm.vue delete mode 100644 extralit-frontend/components/v2/review/ReviewRecordCard.vue delete mode 100644 extralit-frontend/e2e/v2/draft-lifecycle.spec.ts delete mode 100644 extralit-frontend/e2e/v2/review-loop.spec.ts delete mode 100644 extralit-frontend/e2e/v2/slashed-reference.spec.ts delete mode 100644 extralit-frontend/pages/references/[...reference].vue delete mode 100644 extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts delete mode 100644 extralit-frontend/pages/references/useReferenceReviewViewModel.ts delete mode 100644 extralit-frontend/v2/domain/entities/review/ReferenceReview.ts create mode 100644 extralit-frontend/v2/domain/entities/review/ReviewCell.ts delete mode 100644 extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts delete mode 100644 extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts delete mode 100644 extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts diff --git a/extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts b/extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts deleted file mode 100644 index 67d0d485e..000000000 --- a/extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mount } from "@vue/test-utils"; -import ProjectionReviewForm from "./ProjectionReviewForm.vue"; -import { ReferenceReview, ReviewCell, ReviewRecord } from "~/v2/domain/entities/review/ReferenceReview"; -import { Question } from "~/v2/domain/entities/question/Question"; - -const textQuestion = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); -const labelQuestion = new Question( - "q-label", - "s-1", - "label", - "Label", - null, - "label_selection", - ["label"], - { - type: "label_selection", - options: [{ value: "a", text: "A", description: null }], - }, - false -); - -const makeReview = (cells: ReviewCell[], draft = null, orphaned: { name: string; value: unknown }[] = []) => - new ReferenceReview("10.1000/j.x", [new ReviewRecord("r-1", "s-1", "sample_size", cells, [], orphaned, draft)], 1); - -const stubs = { - // Leaves are exercised in their own suites; here we assert dispatch + emit shaping. - ContentEditableFeedbackTask: { template: "
", props: ["value"] }, - LabelSelectionComponent: { template: "
", props: ["modelValue"] }, - RatingMonoSelectionComponent: true, - DndSelectionComponent: true, - V2TableEditor: true, -}; - -describe("ProjectionReviewForm", () => { - it("renders a widget per question type and suggestion provenance", () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), - new ReviewCell(labelQuestion, null, null, null, false), - ]); - - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.find(".stub-text").exists()).toBe(true); - expect(wrapper.find(".stub-label").exists()).toBe(true); - expect(wrapper.text()).toContain("review.suggestion"); - expect(wrapper.text()).toContain("gpt"); - }); - - it("emits submit with (recordId, plain values) — page wraps them", async () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - await wrapper.get("[data-test='submit-r-1']").trigger("click"); - - expect(wrapper.emitted("submit")).toEqual([["r-1", { size: "12" }]]); - }); - - it("marks not-applicable cells and excludes them from emitted values", async () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", null, false), - new ReviewCell(labelQuestion, "a", "suggestion", null, true), - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.text()).toContain("review.notApplicable"); - await wrapper.get("[data-test='save-draft-r-1']").trigger("click"); - expect(wrapper.emitted("save-draft")).toEqual([["r-1", { size: "12" }]]); - }); - - it("surfaces orphaned values read-only and never includes them in emits", async () => { - const review = makeReview([new ReviewCell(textQuestion, "12", null, null, false)], null, [ - { name: "ghost", value: "zzz" }, - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.text()).toContain("review.orphanedValues"); - expect(wrapper.text()).toContain("ghost"); - await wrapper.get("[data-test='submit-r-1']").trigger("click"); - expect(wrapper.emitted("submit")![0][1]).not.toHaveProperty("ghost"); - }); - - it("emits discard with the record id and renders submit errors passed back by the page", async () => { - const review = makeReview([new ReviewCell(textQuestion, null, null, null, false)]); - const wrapper = mount(ProjectionReviewForm, { - props: { review, submitErrors: { "r-1": ["missing value for required question: size"] } }, - global: { stubs }, - }); - - expect(wrapper.text()).toContain("missing value for required question: size"); - await wrapper.get("[data-test='discard-r-1']").trigger("click"); - expect(wrapper.emitted("discard")).toEqual([["r-1"]]); - }); -}); diff --git a/extralit-frontend/components/v2/review/ProjectionReviewForm.vue b/extralit-frontend/components/v2/review/ProjectionReviewForm.vue deleted file mode 100644 index cfe24f1e2..000000000 --- a/extralit-frontend/components/v2/review/ProjectionReviewForm.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/extralit-frontend/components/v2/review/ReviewCellInput.test.ts b/extralit-frontend/components/v2/review/ReviewCellInput.test.ts index ef94d9801..e93e00cef 100644 --- a/extralit-frontend/components/v2/review/ReviewCellInput.test.ts +++ b/extralit-frontend/components/v2/review/ReviewCellInput.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { defineComponent, h } from "vue"; import { mount } from "@vue/test-utils"; import ReviewCellInput from "./ReviewCellInput.vue"; -import { ReviewCell } from "~/v2/domain/entities/review/ReferenceReview"; +import { ReviewCell } from "~/v2/domain/entities/review/ReviewCell"; import { Question } from "~/v2/domain/entities/question/Question"; // Stubs that actually emit the leaf-widget events (the real leaves mutate their bound diff --git a/extralit-frontend/components/v2/review/ReviewCellInput.vue b/extralit-frontend/components/v2/review/ReviewCellInput.vue index eeb2e4e1e..75a22142b 100644 --- a/extralit-frontend/components/v2/review/ReviewCellInput.vue +++ b/extralit-frontend/components/v2/review/ReviewCellInput.vue @@ -52,7 +52,7 @@ - - diff --git a/extralit-frontend/components/v2/schemas/V2RecordsTable.vue b/extralit-frontend/components/v2/schemas/V2RecordsTable.vue index 8e616fb29..4914a7273 100644 --- a/extralit-frontend/components/v2/schemas/V2RecordsTable.vue +++ b/extralit-frontend/components/v2/schemas/V2RecordsTable.vue @@ -10,12 +10,7 @@ - - {{ record.reference }} - + {{ record.reference }} {{ formatCell(record.fields[column.name]) }} diff --git a/extralit-frontend/e2e/v2/draft-lifecycle.spec.ts b/extralit-frontend/e2e/v2/draft-lifecycle.spec.ts deleted file mode 100644 index df1073e62..000000000 --- a/extralit-frontend/e2e/v2/draft-lifecycle.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createIsolatedRecord, expect, loadSeed, signIn, test } from "./fixtures"; - -// Seam C (spec §10.1-C): drafts have ZERO server-side tests. This spec is the gate: -// a draft restores into the form on reload while the projection still shows the suggestion; -// submitting then flips the projection to response. Uses its own isolated record so its -// submit at the end never contaminates another spec's clean-suggestion precondition. -test("draft persists in the form without touching the projection, then submits", async ({ page, request }) => { - const seed = loadSeed(); - const { reference, recordId } = await createIsolatedRecord(request, "10.2000/e2e-draft-lifecycle"); - - await signIn(page); - await page.goto(`/references/${encodeURIComponent(reference)}?workspace_id=${seed.workspaceId}`); - - const sizeCell = page.locator("[data-question='size']"); - const editor = sizeCell.locator("[contenteditable]"); - await editor.click(); - await editor.fill("777"); - - const draftPut = page.waitForResponse( - (r) => r.url().includes(`/records/${recordId}/responses`) && r.request().method() === "PUT" - ); - await page.locator(`[data-test='save-draft-${recordId}']`).click(); - expect((await draftPut).status()).toBe(200); - - await page.reload(); - // Form restores the draft value... - await expect(sizeCell.locator("[contenteditable]")).toHaveText("777"); - // ...but the projection still resolves the suggestion (draft must not project). - await expect(sizeCell.getByText("Suggestion")).toBeVisible(); - - await page.locator(`[data-test='submit-${recordId}']`).click(); - await page.reload(); - await expect(sizeCell.getByText("Response")).toBeVisible(); -}); diff --git a/extralit-frontend/e2e/v2/review-loop.spec.ts b/extralit-frontend/e2e/v2/review-loop.spec.ts deleted file mode 100644 index b16b482ba..000000000 --- a/extralit-frontend/e2e/v2/review-loop.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createIsolatedRecord, expect, loadSeed, signIn, test } from "./fixtures"; - -// The core product loop (spec §10.2-3): suggestion shown with provenance → edit → submit → -// projection re-read flips source to response. Never chained over HTTP in the server suites. -// Uses its own isolated record so it never shares mutable response state with other specs. -test("converts a suggestion into a submitted response", async ({ page, request }) => { - const seed = loadSeed(); - const { reference, recordId } = await createIsolatedRecord(request, "10.2000/e2e-review-loop"); - - await signIn(page); - await page.goto(`/references/${encodeURIComponent(reference)}?workspace_id=${seed.workspaceId}`); - - const sizeCell = page.locator("[data-question='size']"); - await expect(sizeCell).toBeVisible(); - await expect(sizeCell.getByText("Suggestion")).toBeVisible(); - await expect(sizeCell.getByText("e2e-seeder")).toBeVisible(); - - // Edit the text answer (ContentEditableFeedbackTask renders a contenteditable paragraph). - const editor = sizeCell.locator("[contenteditable]"); - await editor.click(); - await editor.fill("135"); - - const putResponse = page.waitForResponse( - (r) => r.url().includes(`/api/v2/records/${recordId}/responses`) && r.request().method() === "PUT" - ); - await page.locator(`[data-test='submit-${recordId}']`).click(); - expect((await putResponse).status()).toBe(200); - - // Reload: the projection must now resolve from the submitted response. - await page.reload(); - await expect(sizeCell.getByText("Response")).toBeVisible(); - await expect(sizeCell.getByText("Suggestion")).not.toBeVisible(); -}); diff --git a/extralit-frontend/e2e/v2/slashed-reference.spec.ts b/extralit-frontend/e2e/v2/slashed-reference.spec.ts deleted file mode 100644 index 5bba0c06e..000000000 --- a/extralit-frontend/e2e/v2/slashed-reference.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// Seam B (spec §10.1-B): %2F-encoded DOI through Nuxt devProxy + uvicorn, untested server-side -// for the projection route. Assert both v2 reference endpoints round-trip. -test("opens a reference containing a slash via the encoded URL", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - - const projectionRequest = page.waitForResponse( - (r) => r.url().includes("/api/v2/projection/references/") && r.request().method() === "GET" - ); - const recordsRequest = page.waitForResponse( - (r) => r.url().includes(`/api/v2/schemas/${seed.schemaId}/records`) && r.request().method() === "GET" - ); - - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - expect((await projectionRequest).status()).toBe(200); - expect((await recordsRequest).status()).toBe(200); - - await expect(page.getByText(seed.reference)).toBeVisible(); - await expect(page.locator("[data-question='size']")).toBeVisible(); -}); diff --git a/extralit-frontend/pages/references/[...reference].vue b/extralit-frontend/pages/references/[...reference].vue deleted file mode 100644 index 84d0c6163..000000000 --- a/extralit-frontend/pages/references/[...reference].vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - diff --git a/extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts b/extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts deleted file mode 100644 index b7c8ab70a..000000000 --- a/extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPinia, setActivePinia } from "pinia"; -import Container from "ts-injecty"; -import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; -import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; -import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; -import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; -import { useReferenceReviewViewModel } from "./useReferenceReviewViewModel"; - -vi.mock("~/v1/infrastructure/services/useNotifications", () => ({ - useNotifications: () => ({ notify: vi.fn() }), -})); -vi.mock("~/v1/infrastructure/services/useTranslate", () => ({ - useTranslate: () => ({ t: (key: string) => key, tc: (key: string) => key }), -})); - -const REVIEW = new ReferenceReview("10.1000/j.x", [], 0); - -describe("useReferenceReviewViewModel", () => { - beforeEach(() => { - // Reset the global ts-injecty container so each test's useResolveMock wins. - Container.dispose(); - setActivePinia(createPinia()); - }); - - it("loads the review on mount-equivalent call and exposes it", async () => { - const execute = vi.fn(async () => REVIEW); - useResolveMock(GetReferenceReviewUseCase, { execute }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn() }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.load(); - - expect(execute).toHaveBeenCalledWith("10.1000/j.x", "w-1"); - // review is held in a ref, so Vue reactive-wraps it (desirable — the form iterates - // records reactively); assert deep equality rather than raw object identity. - expect(vm.review.value).toEqual(REVIEW); - }); - - it("collects normalized 422 messages per record on submit failure, then clears on success", async () => { - useResolveMock(GetReferenceReviewUseCase, { execute: vi.fn(async () => REVIEW) }); - const submit = vi - .fn() - .mockRejectedValueOnce(new ReviewSubmitError(["missing value for required question: size"], 422)) - .mockResolvedValueOnce({ id: "resp" }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: submit }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.onSubmit("r-1", {}); - expect(vm.submitErrors.value["r-1"]).toEqual(["missing value for required question: size"]); - - await vm.onSubmit("r-1", { size: "12" }); - expect(vm.submitErrors.value["r-1"]).toBeUndefined(); - }); - - it("reloads the review after a successful submit so the projection flips to response", async () => { - const load = vi.fn(async () => REVIEW); - useResolveMock(GetReferenceReviewUseCase, { execute: load }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn(async () => ({ id: "resp" })) }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.load(); - await vm.onSubmit("r-1", { size: "12" }); - - expect(load).toHaveBeenCalledTimes(2); - }); -}); diff --git a/extralit-frontend/pages/references/useReferenceReviewViewModel.ts b/extralit-frontend/pages/references/useReferenceReviewViewModel.ts deleted file mode 100644 index ea6ce3d2e..000000000 --- a/extralit-frontend/pages/references/useReferenceReviewViewModel.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ref } from "vue"; -import { useResolve } from "ts-injecty"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; -import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; -import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; -import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; -import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; -import { useTranslate } from "~/v1/infrastructure/services/useTranslate"; - -export const useReferenceReviewViewModel = (reference: string, workspaceId: string) => { - const getReviewUseCase = useResolve(GetReferenceReviewUseCase); - const submitUseCase = useResolve(SubmitReferenceReviewUseCase); - const saveDraftUseCase = useResolve(SaveReviewDraftUseCase); - const discardUseCase = useResolve(DiscardReviewUseCase); - const notifications = useNotifications(); - const { t } = useTranslate(); - - const review = ref(null); - const isLoading = ref(false); - const loadFailed = ref(false); - const submitErrors = ref>({}); - - const load = async () => { - isLoading.value = true; - loadFailed.value = false; - try { - review.value = await getReviewUseCase.execute(reference, workspaceId); - } catch { - loadFailed.value = true; - } finally { - isLoading.value = false; - } - }; - - const runAction = async (recordId: string, action: () => Promise, successKey: string) => { - try { - await action(); - const { [recordId]: _cleared, ...rest } = submitErrors.value; - submitErrors.value = rest; - notifications.notify({ message: t(successKey), type: "success" }); - await load(); // re-read: projection source flips response/suggestion server-side - } catch (error) { - if (error instanceof ReviewSubmitError) { - submitErrors.value = { ...submitErrors.value, [recordId]: error.messages }; - } else { - throw error; - } - } - }; - - const onSubmit = (recordId: string, values: Record) => - runAction(recordId, () => submitUseCase.execute(recordId, values), "review.submitted"); - const onSaveDraft = (recordId: string, values: Record) => - runAction(recordId, () => saveDraftUseCase.execute(recordId, values), "review.draftSaved"); - const onDiscard = (recordId: string) => - runAction(recordId, () => discardUseCase.execute(recordId), "review.discarded"); - - return { review, isLoading, loadFailed, submitErrors, load, onSubmit, onSaveDraft, onDiscard }; -}; diff --git a/extralit-frontend/translation/en.js b/extralit-frontend/translation/en.js index 1b021395c..0160ef1c2 100644 --- a/extralit-frontend/translation/en.js +++ b/extralit-frontend/translation/en.js @@ -141,21 +141,9 @@ export default { loadError: "Could not load the extraction table.", }, review: { - title: "Review", - submit: "Submit", - saveDraft: "Save draft", - discard: "Discard", - suggestion: "Suggestion", - response: "Response", agent: "Agent", score: "Score", - context: "Context", notApplicable: "Not applicable in this schema version", - orphanedValues: "Values from removed questions (read-only, not resubmitted)", - loadError: "Could not load review for this reference.", - submitted: "Response submitted", - draftSaved: "Draft saved", - discarded: "Response discarded", }, settings: { title: "Dataset settings", diff --git a/extralit-frontend/v2/di/di.ts b/extralit-frontend/v2/di/di.ts index 5550877a8..dad03a6aa 100644 --- a/extralit-frontend/v2/di/di.ts +++ b/extralit-frontend/v2/di/di.ts @@ -7,13 +7,11 @@ import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaReposit import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; import { AnnotationRepository } from "~/v2/infrastructure/repositories/AnnotationRepository"; import { ProjectionRepository } from "~/v2/infrastructure/repositories/ProjectionRepository"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; import { GetWorkspaceProjectionUseCase } from "~/v2/domain/usecases/get-workspace-projection-use-case"; import { useExtractions } from "~/v2/infrastructure/storage/ExtractionsStorage"; import { SubmitReferenceReviewUseCase } from "~/v2/domain/usecases/submit-reference-review-use-case"; import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { useReferenceReviews } from "~/v2/infrastructure/storage/ReferenceReviewsStorage"; import { useSchemas } from "~/v2/infrastructure/storage/SchemasStorage"; import { GetSchemasUseCase } from "~/v2/domain/usecases/get-schemas-use-case"; import { GetSchemaSettingsUseCase } from "~/v2/domain/usecases/get-schema-settings-use-case"; @@ -46,15 +44,6 @@ export const loadV2DependencyContainer = (nuxtApp: NuxtAppLike) => { register(ProjectionRepository).withDependency(useAxios).build(), register(GetWorkspaceProjectionUseCase).withDependencies(ProjectionRepository, useExtractions).build(), - register(GetReferenceReviewUseCase) - .withDependencies( - ProjectionRepository, - SchemaRepository, - V2RecordRepository, - AnnotationRepository, - useReferenceReviews - ) - .build(), register(SubmitReferenceReviewUseCase).withDependency(AnnotationRepository).build(), register(SaveReviewDraftUseCase).withDependency(AnnotationRepository).build(), register(DiscardReviewUseCase).withDependency(AnnotationRepository).build(), diff --git a/extralit-frontend/v2/domain/entities/review/ReferenceReview.ts b/extralit-frontend/v2/domain/entities/review/ReferenceReview.ts deleted file mode 100644 index 4fc52acdf..000000000 --- a/extralit-frontend/v2/domain/entities/review/ReferenceReview.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Question } from "../question/Question"; -import { type ColumnMeta } from "../schema/ColumnMeta"; -import { type RecordResponse } from "~/v2/infrastructure/repositories/AnnotationRepository"; - -export interface Provenance { - agent: string | null; - score: number | null; - suggestedValue: unknown; -} - -export class ReviewCell { - constructor( - public readonly question: Question, - public readonly value: unknown, - public readonly source: "response" | "suggestion" | null, - public readonly provenance: Provenance | null, - // The question binds a column absent from this record's pinned version cache (§17.3). - public readonly notApplicable: boolean - ) {} -} - -export interface ContextField { - column: ColumnMeta; - value: unknown; -} - -// Response values keyed by a name no current question owns (deleted/recreated question, -// spec §10.1-E): surfaced read-only, never re-submitted (server would 422 them). -export interface OrphanedValue { - name: string; - value: unknown; -} - -export class ReviewRecord { - constructor( - public readonly recordId: string, - public readonly schemaId: string, - public readonly schemaName: string, - public readonly cells: ReviewCell[], - public readonly contextFields: ContextField[], - public readonly orphanedValues: OrphanedValue[], - public readonly draft: RecordResponse | null, // status === "draft" only - // Pinned version's columns_cache — table-question sub-columns derive editors from it. - public readonly columnsCache: ColumnMeta[] = [] - ) {} - - initialValues(): Record { - const values: Record = {}; - for (const cell of this.cells) { - if (cell.notApplicable) continue; - const draftValue = this.draft?.values[cell.question.name]; - const value = draftValue !== undefined ? draftValue : cell.value; - if (value !== null && value !== undefined) values[cell.question.name] = value; - } - return values; - } -} - -export class ReferenceReview { - constructor( - public readonly reference: string, - public readonly records: ReviewRecord[], - public readonly totalRecords: number - ) {} -} diff --git a/extralit-frontend/v2/domain/entities/review/ReviewCell.ts b/extralit-frontend/v2/domain/entities/review/ReviewCell.ts new file mode 100644 index 000000000..b17d5a414 --- /dev/null +++ b/extralit-frontend/v2/domain/entities/review/ReviewCell.ts @@ -0,0 +1,18 @@ +import { Question } from "../question/Question"; + +export interface Provenance { + agent: string | null; + score: number | null; + suggestedValue: unknown; +} + +export class ReviewCell { + constructor( + public readonly question: Question, + public readonly value: unknown, + public readonly source: "response" | "suggestion" | null, + public readonly provenance: Provenance | null, + // The question binds a column absent from this record's pinned version cache (§17.3). + public readonly notApplicable: boolean + ) {} +} diff --git a/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts b/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts index c21a1fdae..983cb0b57 100644 --- a/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts +++ b/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { Question } from "../question/Question"; -import { ReviewCell } from "./ReferenceReview"; +import { ReviewCell } from "./ReviewCell"; import { buildLabelOptions, buildRankingValues, diff --git a/extralit-frontend/v2/domain/entities/review/widget-adapters.ts b/extralit-frontend/v2/domain/entities/review/widget-adapters.ts index c8c4c2c34..be08fbac2 100644 --- a/extralit-frontend/v2/domain/entities/review/widget-adapters.ts +++ b/extralit-frontend/v2/domain/entities/review/widget-adapters.ts @@ -1,5 +1,5 @@ import { Question } from "../question/Question"; -import { type ReviewCell } from "./ReferenceReview"; +import { type ReviewCell } from "./ReviewCell"; import { SuggestionHint } from "./SuggestionHint"; // Adapters between server value shapes and the extracted leaf-widget option shapes diff --git a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts b/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts deleted file mode 100644 index 5a071b2f4..000000000 --- a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPinia, setActivePinia } from "pinia"; -import { GetReferenceReviewUseCase } from "./get-reference-review-use-case"; -import { Question } from "../entities/question/Question"; -import { Schema } from "../entities/schema/Schema"; -import { SchemaVersion } from "../entities/schema/SchemaVersion"; -import { ColumnMeta } from "../entities/schema/ColumnMeta"; -import { V2Record } from "../entities/record/V2Record"; -import { RecordsPage } from "../entities/record/RecordsPage"; -import { useReferenceReviews } from "~/v2/infrastructure/storage/ReferenceReviewsStorage"; - -const REFERENCE = "10.1000/j.x"; -const WORKSPACE = "w-1"; - -const sizeQuestion = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); -const record = new V2Record( - "r-1", - "s-1", - "v-1", - REFERENCE, - null, - { size: "12", country: "KE" }, - null, - "pending", - "", - "" -); - -const projectionRepository = { - getProjection: vi.fn(async () => ({ - reference: REFERENCE, - totalRecords: 1, - records: [ - { - recordId: "r-1", - schemaId: "s-1", - reference: REFERENCE, - cells: [{ questionName: "size", value: "12", source: "suggestion" as const }], - }, - ], - })), -}; - -const schemaRepository = { - getSchema: vi.fn(async () => new Schema("s-1", "sample_size", "published", WORKSPACE, "v-1", {}, "", "")), - getQuestions: vi.fn(async () => [sizeQuestion]), - getVersions: vi.fn(async () => [ - new SchemaVersion( - "v-1", - "s-1", - 1, - [new ColumnMeta("size", "str", false, null), new ColumnMeta("country", "str", true, null)], - {}, - "" - ), - ]), -}; - -const recordRepository = { getRecords: vi.fn(async () => new RecordsPage([record], 1)) }; - -const annotationRepository = { - getSuggestions: vi.fn(async () => [ - { id: "sug-1", recordId: "r-1", questionId: "q-size", value: "12", score: 0.9, agent: "gpt" }, - ]), - getResponse: vi.fn(async () => null), -}; - -const makeUseCase = () => - new GetReferenceReviewUseCase( - projectionRepository as never, - schemaRepository as never, - recordRepository as never, - annotationRepository as never, - useReferenceReviews() - ); - -describe("GetReferenceReviewUseCase", () => { - beforeEach(() => { - setActivePinia(createPinia()); - vi.clearAllMocks(); - }); - - it("joins suggestion provenance to cells through the name↔id question map", async () => { - const review = await makeUseCase().execute(REFERENCE, WORKSPACE); - - const cell = review.records[0].cells.find((c) => c.question.name === "size")!; - expect(cell.source).toBe("suggestion"); - expect(cell.provenance).toEqual({ agent: "gpt", score: 0.9, suggestedValue: "12" }); - }); - - it("exposes non-question columns as read-only context fields", async () => { - const review = await makeUseCase().execute(REFERENCE, WORKSPACE); - - expect(review.records[0].contextFields).toEqual([ - { column: expect.objectContaining({ name: "country" }), value: "KE" }, - ]); - }); - - it("marks a question not-applicable when its column is missing from the pinned version cache", async () => { - schemaRepository.getQuestions.mockResolvedValueOnce([ - sizeQuestion, - new Question("q-new", "s-1", "added_later", "Added later", null, "text", ["added_later"], {}, false), - ]); - - const review = await makeUseCase().execute(REFERENCE, WORKSPACE); - - expect(review.records[0].cells.find((c) => c.question.name === "added_later")?.notApplicable).toBe(true); - }); - - it("collects response values orphaned by deleted questions and keeps a draft for prefill", async () => { - annotationRepository.getResponse.mockResolvedValueOnce({ - id: "resp-1", - recordId: "r-1", - userId: "u-1", - values: { size: "13", ghost_question: "zzz" }, - status: "draft", - }); - - const review = await makeUseCase().execute(REFERENCE, WORKSPACE); - const reviewRecord = review.records[0]; - - expect(reviewRecord.orphanedValues).toEqual([{ name: "ghost_question", value: "zzz" }]); - expect(reviewRecord.draft?.status).toBe("draft"); - // draft wins over the projection cell for prefill; orphans are excluded - expect(reviewRecord.initialValues()).toEqual({ size: "13" }); - }); - - it("prefills from the projection cell when there is no draft, and saves to storage by reference", async () => { - const review = await makeUseCase().execute(REFERENCE, WORKSPACE); - - expect(review.records[0].initialValues()).toEqual({ size: "12" }); - // The Pinia-backed store reconstructs+reactive-wraps on read (v1/store/non-reactive), - // so assert deep equality, not object identity (matches SchemasStorage's test posture). - expect(useReferenceReviews().findByReference(REFERENCE)).toEqual(review); - }); - - it("ignores a submitted response for prefill (projection already reflects it)", async () => { - annotationRepository.getResponse.mockResolvedValueOnce({ - id: "resp-1", - recordId: "r-1", - userId: "u-1", - values: { size: "12" }, - status: "submitted", - }); - - const review = await makeUseCase().execute(REFERENCE, WORKSPACE); - - expect(review.records[0].draft).toBeNull(); - }); - - it("resolves each record against its own schema for a multi-schema reference", async () => { - // Local mocks that branch on schemaId/recordId — the whole point of the schemaId-keyed - // contexts/records/version maps is that a reference spanning schemas resolves each record - // against its own schema's questions, columns and version. - const s1 = new Schema("s-1", "sample_size", "published", WORKSPACE, "v-1", {}, "", ""); - const s2 = new Schema("s-2", "outcomes", "published", WORKSPACE, "v-2", {}, "", ""); - const q1 = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); - const q2 = new Question("q-year", "s-2", "year", "Year", null, "text", ["year"], {}, true); - const v1 = new SchemaVersion( - "v-1", - "s-1", - 1, - [new ColumnMeta("size", "str", false, null), new ColumnMeta("country", "str", true, null)], - {}, - "" - ); - const v2 = new SchemaVersion( - "v-2", - "s-2", - 1, - [new ColumnMeta("year", "str", false, null), new ColumnMeta("region", "str", true, null)], - {}, - "" - ); - const rec1 = new V2Record( - "r-1", - "s-1", - "v-1", - REFERENCE, - null, - { size: "12", country: "KE" }, - null, - "pending", - "", - "" - ); - const rec2 = new V2Record( - "r-2", - "s-2", - "v-2", - REFERENCE, - null, - { year: "2021", region: "EA" }, - null, - "pending", - "", - "" - ); - - const projection = { - getProjection: vi.fn(async () => ({ - reference: REFERENCE, - totalRecords: 2, - records: [ - { - recordId: "r-1", - schemaId: "s-1", - reference: REFERENCE, - cells: [{ questionName: "size", value: "12", source: "suggestion" as const }], - }, - { - recordId: "r-2", - schemaId: "s-2", - reference: REFERENCE, - cells: [{ questionName: "year", value: "2021", source: "response" as const }], - }, - ], - })), - }; - const schema = { - getSchema: vi.fn(async (id: string) => (id === "s-1" ? s1 : s2)), - getQuestions: vi.fn(async (id: string) => (id === "s-1" ? [q1] : [q2])), - getVersions: vi.fn(async (id: string) => (id === "s-1" ? [v1] : [v2])), - }; - const records = { getRecords: vi.fn(async (id: string) => new RecordsPage(id === "s-1" ? [rec1] : [rec2], 1)) }; - const annotation = { getSuggestions: vi.fn(async () => []), getResponse: vi.fn(async () => null) }; - - const review = await new GetReferenceReviewUseCase( - projection as never, - schema as never, - records as never, - annotation as never, - useReferenceReviews() - ).execute(REFERENCE, WORKSPACE); - - const r1 = review.records.find((r) => r.recordId === "r-1")!; - const r2 = review.records.find((r) => r.recordId === "r-2")!; - expect(r1.schemaName).toBe("sample_size"); - expect(r1.cells.map((c) => c.question.name)).toEqual(["size"]); - expect(r1.contextFields).toEqual([{ column: expect.objectContaining({ name: "country" }), value: "KE" }]); - expect(r2.schemaName).toBe("outcomes"); - expect(r2.cells.map((c) => c.question.name)).toEqual(["year"]); - expect(r2.contextFields).toEqual([{ column: expect.objectContaining({ name: "region" }), value: "EA" }]); - }); -}); diff --git a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts b/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts deleted file mode 100644 index f686869dd..000000000 --- a/extralit-frontend/v2/domain/usecases/get-reference-review-use-case.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Question } from "../entities/question/Question"; -import { SchemaVersion } from "../entities/schema/SchemaVersion"; -import { - type ContextField, - type OrphanedValue, - type Provenance, - ReferenceReview, - ReviewCell, - ReviewRecord, -} from "../entities/review/ReferenceReview"; -import { SchemaRepository } from "~/v2/infrastructure/repositories/SchemaRepository"; -import { V2RecordRepository } from "~/v2/infrastructure/repositories/V2RecordRepository"; -import { ProjectionRepository, type ProjectionRecordDto } from "~/v2/infrastructure/repositories/ProjectionRepository"; -import { AnnotationRepository, type RecordSuggestion } from "~/v2/infrastructure/repositories/AnnotationRepository"; -import { type useReferenceReviews } from "~/v2/infrastructure/storage/ReferenceReviewsStorage"; - -interface SchemaContext { - schemaName: string; - questions: Question[]; - questionsById: Map; - questionsByName: Map; - versionsById: Map; -} - -export class GetReferenceReviewUseCase { - constructor( - private readonly projectionRepository: ProjectionRepository, - private readonly schemaRepository: SchemaRepository, - private readonly recordRepository: V2RecordRepository, - private readonly annotationRepository: AnnotationRepository, - // ts-injecty resolves the `useReferenceReviews` hook by calling it, so the injected - // value is the store object, not the hook (same contract as v1 GetWorkspacesUseCase). - private readonly reviewsStorage: ReturnType - ) {} - - async execute(reference: string, workspaceId: string): Promise { - const projection = await this.projectionRepository.getProjection(reference, workspaceId); - - const schemaIds = [...new Set(projection.records.map((r) => r.schemaId))]; - const contexts = new Map(); - const recordsBySchema = new Map>>(); - const versionByRecord = new Map(); - - await Promise.all( - schemaIds.map(async (schemaId) => { - const [schema, questions, versions, page] = await Promise.all([ - this.schemaRepository.getSchema(schemaId), - this.schemaRepository.getQuestions(schemaId), - this.schemaRepository.getVersions(schemaId), - this.recordRepository.getRecords(schemaId, { reference }), - ]); - contexts.set(schemaId, { - schemaName: schema.name, - questions, - // The name↔id join: projection cells + response values key by NAME, - // suggestions key by ID (spec §7). Getting this wrong detaches provenance. - questionsById: new Map(questions.map((q) => [q.id, q])), - questionsByName: new Map(questions.map((q) => [q.name, q])), - versionsById: new Map(versions.map((v) => [v.id, v])), - }); - recordsBySchema.set(schemaId, new Map(page.items.map((r) => [r.id, r.fields]))); - page.items.forEach((r) => versionByRecord.set(r.id, r.schemaVersionId)); - }) - ); - - const reviewRecords = await Promise.all( - projection.records.map((projected) => this.assembleRecord(projected, contexts, recordsBySchema, versionByRecord)) - ); - - const review = new ReferenceReview(reference, reviewRecords, projection.totalRecords); - this.reviewsStorage.saveReview(review); - return review; - } - - private async assembleRecord( - projected: ProjectionRecordDto, - contexts: Map, - recordsBySchema: Map>>, - versionByRecord: Map - ): Promise { - const context = contexts.get(projected.schemaId)!; - const fields = recordsBySchema.get(projected.schemaId)?.get(projected.recordId) ?? {}; - const pinnedVersion = context.versionsById.get(versionByRecord.get(projected.recordId) ?? ""); - - const [suggestions, response] = await Promise.all([ - this.annotationRepository.getSuggestions(projected.recordId), - this.annotationRepository.getResponse(projected.recordId), - ]); - const suggestionsByQuestionId = new Map(suggestions.map((s) => [s.questionId, s])); - const cellsByName = new Map(projected.cells.map((c) => [c.questionName, c])); - - const cells = context.questions.map((question) => { - const cell = cellsByName.get(question.name); - const suggestion = suggestionsByQuestionId.get(question.id); - const provenance: Provenance | null = suggestion - ? { - agent: suggestion.agent, - score: Array.isArray(suggestion.score) ? (suggestion.score[0] ?? null) : suggestion.score, - suggestedValue: suggestion.value, - } - : null; - // Old-version tolerance (§17.3): every bound column must exist in the pinned cache. - const notApplicable = - pinnedVersion !== undefined && question.columns.some((c) => pinnedVersion.findColumn(c) === undefined); - - return new ReviewCell(question, cell?.value ?? null, cell?.source ?? null, provenance, notApplicable); - }); - - const questionColumns = new Set(context.questions.flatMap((q) => q.columns)); - const contextFields: ContextField[] = (pinnedVersion?.columnsCache ?? []) - .filter((column) => !questionColumns.has(column.name)) - .map((column) => ({ column, value: fields[column.name] ?? null })); - - const orphanedValues: OrphanedValue[] = Object.entries(response?.values ?? {}) - .filter(([name]) => !context.questionsByName.has(name)) - .map(([name, value]) => ({ name, value })); - - const draft = response?.status === "draft" ? response : null; - - return new ReviewRecord( - projected.recordId, - projected.schemaId, - context.schemaName, - cells, - contextFields, - orphanedValues, - draft, - pinnedVersion?.columnsCache ?? [] - ); - } -} diff --git a/extralit-frontend/v2/domain/usecases/get-workspace-projection-use-case.ts b/extralit-frontend/v2/domain/usecases/get-workspace-projection-use-case.ts index e68302fd3..6de4ec23a 100644 --- a/extralit-frontend/v2/domain/usecases/get-workspace-projection-use-case.ts +++ b/extralit-frontend/v2/domain/usecases/get-workspace-projection-use-case.ts @@ -13,7 +13,7 @@ export class GetWorkspaceProjectionUseCase { constructor( private readonly projectionRepository: ProjectionRepository, // ts-injecty resolves the `useExtractions` hook by calling it, so the injected - // value is the store object, not the hook (same contract as GetReferenceReviewUseCase). + // value is the store object, not the hook. private readonly extractionsStorage: ReturnType ) {} diff --git a/extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts b/extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts deleted file mode 100644 index 97b52db44..000000000 --- a/extralit-frontend/v2/infrastructure/storage/ReferenceReviewsStorage.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useStoreFor } from "@/v1/store/create"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; - -// Keyed by reference, not by route, so Phase 5's Queue UI can drive the same store -// with references from GET /queues/{id}/next (spec §7). -class ReferenceReviews { - constructor(public readonly byReference: Record = {}) {} -} - -interface IReferenceReviewsStorage { - saveReview(review: ReferenceReview): void; - findByReference(reference: string): ReferenceReview | undefined; -} - -const useStoreForReferenceReviews = useStoreFor(ReferenceReviews); - -export const useReferenceReviews = () => { - const store = useStoreForReferenceReviews(); - - const saveReview = (review: ReferenceReview) => { - store.save(new ReferenceReviews({ ...store.get().byReference, [review.reference]: review })); - }; - - const findByReference = (reference: string): ReferenceReview | undefined => store.get().byReference[reference]; - - return { ...store, saveReview, findByReference }; -}; From 5bed6c91d80b0dc778913c0844a2ee94a8e5cc0e Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 02:04:23 -0700 Subject: [PATCH 11/43] fix(v2-ui): restore dynamically-referenced review provenance i18n keys --- .../v2/review/ReviewProvenance.test.ts | 66 +++++++++++++++++++ extralit-frontend/translation/en.js | 2 + 2 files changed, 68 insertions(+) create mode 100644 extralit-frontend/components/v2/review/ReviewProvenance.test.ts diff --git a/extralit-frontend/components/v2/review/ReviewProvenance.test.ts b/extralit-frontend/components/v2/review/ReviewProvenance.test.ts new file mode 100644 index 000000000..82e6d8ac0 --- /dev/null +++ b/extralit-frontend/components/v2/review/ReviewProvenance.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import ReviewProvenance from "./ReviewProvenance.vue"; +// Real message catalog (not the `#key#`-echoing global test stub from test/setup.ts, +// which would make this regression test hollow: it never actually looks a key up, so it +// can never fail when a key is deleted from en.js). A real vue-i18n instance built from +// the real catalog falls back to rendering the raw key when a message is missing, which is +// exactly the silent regression this test needs to catch: ReviewProvenance.vue looks its +// key up dynamically — `$t(`review.${source}`)` — so a plain grep over en.js for the +// literal strings "review.response"/"review.suggestion" finds nothing, and the previous +// i18n cleanup deleted both keys as "unreferenced". +import en from "~/translation/en"; + +// Under this repo's Vitest/Vite pipeline (@nuxtjs/i18n's build-time transform), the +// imported `en` module is precompiled: leaf message strings become message-compiler AST +// nodes, not plain strings. That's fine for feeding into `createI18n` (vue-i18n resolves +// them the same way the running app does) but means `en.review.response` itself isn't a +// bare string to assert against here — hence the expected labels below are asserted +// literally, and separately cross-checked against the raw source text of en.js. +// +// The compiler also mutates its input messages object in place (caching compiled nodes +// over the original values); `en` is the real, singleton-cached module export, so mutating +// it here would corrupt every other spec that imports translation/en. Deep-clone first. +// `@nuxtjs/i18n`'s generated module augmentation types `messages` against every +// configured locale (en/de/es/ja) — this spec only needs `en`, so the rest are empty. +const i18n = createI18n({ + legacy: false, + locale: "en", + fallbackLocale: "en", + messages: { en: structuredClone(en), de: {}, es: {}, ja: {} }, +}); + +const mountProvenance = (source: "response" | "suggestion") => + mount(ReviewProvenance, { + props: { source, provenance: null }, + // Override the repo-wide `$t` -> `#key#` stub (test/setup.ts) with the real vue-i18n + // `t` resolved against the actual en.js catalog, for this mount only. + global: { mocks: { $t: i18n.global.t } }, + }); + +// Original values recovered verbatim from git (293466ae8^:extralit-frontend/translation/en.js). +const EXPECTED = { response: "Response", suggestion: "Suggestion" } as const; + +const en_js_source = readFileSync(resolve(process.cwd(), "translation/en.js"), "utf-8"); + +describe("ReviewProvenance dynamic source lookup ($t(`review.${source}`))", () => { + it.each(["response", "suggestion"] as const)( + "resolves the %s provenance label from the real catalog, not the raw key", + (source) => { + const wrapper = mountProvenance(source); + + expect(wrapper.text()).toContain(EXPECTED[source]); + expect(wrapper.text()).not.toContain(`review.${source}`); + } + ); + + it.each(["response", "suggestion"] as const)("keeps review.%s present in the checked-in en.js catalog", (key) => { + // Belt-and-suspenders: assert directly against the source text, independent of the + // build-time i18n transform above, so a future edit to en.js is caught even if the + // transform/mount plumbing above ever changes. + expect(en_js_source).toMatch(new RegExp(`\\b${key}:\\s*"${EXPECTED[key]}"`)); + }); +}); diff --git a/extralit-frontend/translation/en.js b/extralit-frontend/translation/en.js index 0160ef1c2..336d41b44 100644 --- a/extralit-frontend/translation/en.js +++ b/extralit-frontend/translation/en.js @@ -141,6 +141,8 @@ export default { loadError: "Could not load the extraction table.", }, review: { + suggestion: "Suggestion", + response: "Response", agent: "Agent", score: "Score", notApplicable: "Not applicable in this schema version", From beb79a51ca09b8cb6c442af5d6ae28a46e9f4d33 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 02:21:12 -0700 Subject: [PATCH 12/43] test(v2-ui): extractions-grid e2e gate with coverage-map seed --- .../e2e/v2/extractions-grid.spec.ts | 30 ++++++++++ extralit-frontend/e2e/v2/fixtures.ts | 1 + extralit-frontend/e2e/v2/seed/seed_v2_e2e.py | 59 ++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 extralit-frontend/e2e/v2/extractions-grid.spec.ts diff --git a/extralit-frontend/e2e/v2/extractions-grid.spec.ts b/extralit-frontend/e2e/v2/extractions-grid.spec.ts new file mode 100644 index 000000000..5e5765f7f --- /dev/null +++ b/extralit-frontend/e2e/v2/extractions-grid.spec.ts @@ -0,0 +1,30 @@ +import { expect, loadSeed, signIn, test } from "./fixtures"; + +// Replacement gate for the deleted review-loop specs (spec §3.5 accepted risk): +// seed → grid renders coalesced values → coverage-gap columns present. +test("extraction table renders coalesced values and coverage gaps", async ({ page }) => { + const seed = loadSeed(); + await signIn(page); + + const projectionRequest = page.waitForResponse( + (r) => + r.url().includes("/api/v2/projection") && + !r.url().includes("/references/") && + r.request().method() === "GET" + ); + await page.goto(`/extractions?workspace_id=${seed.workspaceId}`); + expect((await projectionRequest).status()).toBe(200); + + const viewer = page.locator("perspective-viewer"); + await expect(viewer).toBeVisible(); + + // Column manifest: both schemas appear — including the record-less one (coverage map). + await expect(page.getByText(`${seed.schemaName}.size`)).toBeVisible(); + await expect(page.getByText(`${seed.emptySchemaName}.notes`)).toBeVisible(); + + // Rows + coalesced values: suggestion-sourced `size`, response-beats-suggestion `label`. + await expect(page.getByText(seed.reference).first()).toBeVisible(); + await expect(page.getByText("120").first()).toBeVisible(); + await expect(page.getByText("control").first()).toBeVisible(); + await expect(page.getByText("intervention")).toHaveCount(0); +}); diff --git a/extralit-frontend/e2e/v2/fixtures.ts b/extralit-frontend/e2e/v2/fixtures.ts index 1289d67d6..b07d33077 100644 --- a/extralit-frontend/e2e/v2/fixtures.ts +++ b/extralit-frontend/e2e/v2/fixtures.ts @@ -6,6 +6,7 @@ export interface SeedOutput { workspaceId: string; schemaId: string; schemaName: string; + emptySchemaName: string; reference: string; recordId: string; questions: Record; diff --git a/extralit-frontend/e2e/v2/seed/seed_v2_e2e.py b/extralit-frontend/e2e/v2/seed/seed_v2_e2e.py index 37ea8d74e..a29707f56 100644 --- a/extralit-frontend/e2e/v2/seed/seed_v2_e2e.py +++ b/extralit-frontend/e2e/v2/seed/seed_v2_e2e.py @@ -15,6 +15,7 @@ import pandera.pandas as pa SCHEMA_NAME = "e2e_v2_slice" +EMPTY_SCHEMA_NAME = "e2e_v2_empty" REFERENCE = "10.1000/j.e2e-v2" # slash on purpose: seam B WORKSPACE_NAME = "e2e-v2" @@ -26,6 +27,14 @@ } ).to_json() +# Coverage-map schema (spec §3.1): one question, zero records — proves the grid still +# renders a column for a schema nobody has annotated yet. +EMPTY_BODY = pa.DataFrameSchema( + columns={ + "notes": pa.Column(pa.String, nullable=True), + } +).to_json() + def main() -> None: parser = argparse.ArgumentParser() @@ -64,7 +73,7 @@ def main() -> None: .json()["items"] ) for schema in schemas: - if schema["name"] == SCHEMA_NAME: + if schema["name"] in (SCHEMA_NAME, EMPTY_SCHEMA_NAME): client.delete(f"/api/v2/schemas/{schema['id']}").raise_for_status() schema = ( client.post( @@ -107,7 +116,11 @@ def main() -> None: "type": qtype, "columns": [name], "settings": settings, - "required": name == "size", + # Neither question is required: the seeded submitted response below + # answers only `label` (to prove response-beats-suggestion there while + # `size` stays suggestion-sourced) and `PUT .../responses` rejects a + # submitted envelope missing any required question's value. + "required": False, }, ) .raise_for_status() @@ -146,13 +159,55 @@ def main() -> None: }, ).raise_for_status() + # Competing `label` suggestion + a submitted response that must win the coalesce + # (spec §3.2 response-beats-suggestion), so the grid proves it, not just the + # per-reference review form. + client.put( + f"/api/v2/records/{record['id']}/suggestions", + json={ + "question_id": questions["label"]["id"], + "value": "intervention", + "score": 0.42, + "agent": "e2e-seeder", + }, + ).raise_for_status() + client.put( + f"/api/v2/records/{record['id']}/responses", + json={"values": {"label": {"value": "control"}}, "status": "submitted"}, + ).raise_for_status() + # Fresh index so the search scenario has something to find. client.post(f"/api/v2/schemas/{schema['id']}:rebuild-index").raise_for_status() + # Coverage-map schema (spec §3.1): one question, zero records. + empty_schema = ( + client.post( + "/api/v2/schemas", + json={"name": EMPTY_SCHEMA_NAME, "workspace_id": workspace["id"]}, + ) + .raise_for_status() + .json() + ) + client.post( + f"/api/v2/schemas/{empty_schema['id']}/versions", json={"body": EMPTY_BODY} + ).raise_for_status() + client.post( + f"/api/v2/schemas/{empty_schema['id']}/questions", + json={ + "name": "notes", + "title": "Notes", + "type": "text", + "columns": ["notes"], + "settings": {}, + "required": False, + }, + ).raise_for_status() + output = { "workspaceId": workspace["id"], "schemaId": schema["id"], "schemaName": SCHEMA_NAME, + "emptySchemaName": EMPTY_SCHEMA_NAME, "reference": REFERENCE, "recordId": record["id"], "questions": questions, From 54cd9905c7a8041a42273cfa0c1b56c6521a8ef1 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 02:39:18 -0700 Subject: [PATCH 13/43] chore(v2-ui): remove dead createIsolatedRecord e2e fixture Its only two callers (review-loop.spec.ts, draft-lifecycle.spec.ts) were deleted earlier in this branch, leaving it an unused export. Confirmed no remaining callers before removal. --- extralit-frontend/e2e/v2/fixtures.ts | 30 ---------------------------- 1 file changed, 30 deletions(-) diff --git a/extralit-frontend/e2e/v2/fixtures.ts b/extralit-frontend/e2e/v2/fixtures.ts index b07d33077..b65e6f032 100644 --- a/extralit-frontend/e2e/v2/fixtures.ts +++ b/extralit-frontend/e2e/v2/fixtures.ts @@ -28,36 +28,6 @@ export const apiToken = async (request: APIRequestContext): Promise => { return (await res.json()).access_token; }; -// Create a fresh record under the seeded schema with its OWN reference plus a seeded -// suggestion on the `size` question. Response-mutating specs (review-loop, draft-lifecycle) -// each seed one of these in beforeEach so they never share the single seed record — which -// otherwise races in parallel and, in serial order, leaves a submitted response that breaks -// the next spec's clean "Suggestion" precondition (roborev job 154). A reseed wipes the schema. -// The reference must NOT contain seed.reference as a substring: other specs assert on the -// shared record via non-exact getByText(seed.reference), and a superstring reference in the -// same schema substring-matches those assertions into strict-mode violations (roborev job 157). -export const createIsolatedRecord = async ( - request: APIRequestContext, - reference: string -): Promise<{ reference: string; recordId: string }> => { - const seed = loadSeed(); - const headers = { Authorization: `Bearer ${await apiToken(request)}` }; - - const upsert = await request.post(`${apiUrl()}/api/v2/schemas/${seed.schemaId}/records:bulk-upsert`, { - headers, - data: { items: [{ fields: { size: "120", label: "control", country: "KE" }, reference }] }, - }); - const recordId = (await upsert.json()).items[0].id; - - await request.put(`${apiUrl()}/api/v2/records/${recordId}/suggestions`, { - headers, - data: { question_id: seed.questions.size.id, value: "120", score: 0.87, agent: "e2e-seeder" }, - }); - await request.post(`${apiUrl()}/api/v2/schemas/${seed.schemaId}:rebuild-index`, { headers }); - - return { reference, recordId }; -}; - // Local chromium cannot launch on the Orin dev host — connect to the remote ccui // chromium over CDP when E2E_CDP_URL is set; fall back to a plain launch (CI). export const test = base.extend({ From 6a9028708e5af548f022d560e3103b29a21f28aa Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 03:11:41 -0700 Subject: [PATCH 14/43] fix(v2-ui): coerce non-scalar cell values and surface Perspective table load failures toPerspectiveData() previously fed multi_label_selection/ranking/span cell values (arrays/objects) straight into Perspective, which only infers scalar column types - client.table() could render [object Object] or reject outright. Serialize non-scalar values to a stable JSON string while passing null and genuine scalars through unchanged. That table-construction call also sat outside performLoad's try/catch, so a rejection escaped as an unhandled rejection: loadFailed stayed false while the viewer rendered empty with no explanation. Guard the call and emit a new `load-error` event that the extractions page wires into its existing loadError state cascade. --- .../ExtractionsGrid.client.test.ts | 17 +++++++ .../v2/extractions/ExtractionsGrid.client.vue | 23 ++++++++- extralit-frontend/pages/extractions/index.vue | 2 +- .../useExtractionsViewModel.test.ts | 17 +++++++ .../extractions/useExtractionsViewModel.ts | 10 +++- .../entities/projection/grid-adapter.test.ts | 47 +++++++++++++++++++ .../entities/projection/grid-adapter.ts | 28 ++++++++++- 7 files changed, 140 insertions(+), 4 deletions(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts index 989ee4d8f..06d27fd75 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts @@ -46,4 +46,21 @@ describe("ExtractionsGrid", () => { expect(initSpy).toHaveBeenCalledTimes(1); expect(tableSpy).toHaveBeenCalledWith([{ reference: "10.1/a", "Design.type": "RCT" }]); }); + + it("emits load-error instead of throwing when building the Perspective table rejects", async () => { + tableSpy.mockRejectedValueOnce(new Error("boom")); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const wrapper = mount(ExtractionsGrid, { + props: { projection: PROJECTION }, + global: { + config: { compilerOptions: { isCustomElement: (tag: string) => tag.startsWith("perspective-") } }, + }, + }); + await flushPromises(); + + expect(wrapper.emitted("load-error")).toHaveLength(1); + + consoleErrorSpy.mockRestore(); + }); }); diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index c929a4f47..46688115c 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -48,6 +48,11 @@ const props = defineProps<{ projection: WorkspaceProjection }>(); const emit = defineEmits<{ "cell-click": [payload: { cell: ProjectionGridCell; reference: string; schemaId: string; columnName: string }]; + // Fired when building/loading a Perspective table for the current projection fails (e.g. + // `client.table()` rejects). The host page maps this onto its existing `loadError` state + // so a rejection here can never again leave the user staring at a blank, unexplained + // (see `performLoad`'s doc comment). + "load-error": []; }>(); const viewerEl = ref(null); @@ -209,7 +214,23 @@ async function performLoad(projection: WorkspaceProjection): Promise { if (!client) { return; } - const newTable = await client.table(toPerspectiveData(projection)); + + let newTable: PerspectiveTableLike; + try { + // Building the table is guarded here (rather than a bare `await` before any try/catch) + // specifically so a rejection — e.g. a non-scalar cell value `client.table()` can't + // infer a schema for, see `toPerspectiveData` — surfaces as `load-error` instead of + // escaping as an unhandled rejection. An unhandled rejection here previously left + // `loadFailed` at `false` while the viewer stayed mounted empty: a silent, total + // failure with no explanation ever shown to the user. + newTable = await client.table(toPerspectiveData(projection)); + } catch (error) { + if (!cancelled) { + console.error("[ExtractionsGrid] failed to build the Perspective table for this projection", error); + emit("load-error"); + } + return; + } if (cancelled) { // Unmount already ran and, finding nothing to clean up at the time, will never run // again — this table would otherwise never be deleted. diff --git a/extralit-frontend/pages/extractions/index.vue b/extralit-frontend/pages/extractions/index.vue index 717850730..317c176d3 100644 --- a/extralit-frontend/pages/extractions/index.vue +++ b/extralit-frontend/pages/extractions/index.vue @@ -12,7 +12,7 @@ - +
diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts index 877b53c64..b29aaf1e3 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -61,6 +61,23 @@ describe("useExtractionsViewModel", () => { expect(url).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); }); + it("flags load failure when the grid reports a load-error after a successful projection load", async () => { + // Reproduces ExtractionsGrid's `load-error` emit (e.g. `client.table()` rejecting on a + // non-scalar cell value): the projection itself loaded fine, but the page's state + // cascade must still fall back to the loadError message instead of leaving the grid + // mounted empty with no explanation. + const projection = new WorkspaceProjection([], [], 0); + executeMock.mockResolvedValue(projection); + + const vm = useExtractionsViewModel("w-1"); + await vm.load(); + expect(vm.loadFailed.value).toBe(false); + + vm.onGridLoadError(); + + expect(vm.loadFailed.value).toBe(true); + }); + it("keeps the last-requested workspace's result when a superseded slow response resolves later", async () => { const firstCall = deferred(); const secondCall = deferred(); diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts index bb2018e54..cfb35ec77 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -76,5 +76,13 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => return url; }; - return { projection, isLoading, loadFailed, workspaceId, load, onCellClick }; + // Wired to `ExtractionsGrid`'s `load-error` emit (see its doc comment): a rejection while + // building/loading the Perspective table for the current projection is surfaced through + // this same `loadFailed` flag the page already renders `extractions.loadError` for, so the + // user is never left staring at a blank, unexplained grid. + const onGridLoadError = (): void => { + loadFailed.value = true; + }; + + return { projection, isLoading, loadFailed, workspaceId, load, onCellClick, onGridLoadError }; }; diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts index a275efb57..114904a1a 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts @@ -89,6 +89,53 @@ describe("toPerspectiveData", () => { const empty = new WorkspaceProjection(COLUMNS, [], 0); expect(toPerspectiveData(empty)).toEqual([]); }); + + it("serializes an array cell value to a stable JSON string (multi_label_selection / ranking)", () => { + const arrayProjection = new WorkspaceProjection( + COLUMNS, + [{ reference: "10.1/a", rowIndex: 0, cells: { "Design.type": cell(["low", "high"]) } }], + 1 + ); + expect(toPerspectiveData(arrayProjection)[0]["Design.type"]).toBe(JSON.stringify(["low", "high"])); + }); + + it("serializes an object cell value to a stable JSON string (span)", () => { + const spanProjection = new WorkspaceProjection( + COLUMNS, + [{ reference: "10.1/a", rowIndex: 0, cells: { "Design.type": cell({ start: 0, end: 3, text: "RCT" }) } }], + 1 + ); + expect(toPerspectiveData(spanProjection)[0]["Design.type"]).toBe(JSON.stringify({ start: 0, end: 3, text: "RCT" })); + }); + + it('keeps an absent cell as the JS value null, not the string "null"', () => { + expect(toPerspectiveData(PROJECTION)[2]["Design.type"]).toBeNull(); + }); + + it('keeps a cell whose value is explicitly null as null, not the string "null"', () => { + const explicitNullProjection = new WorkspaceProjection( + COLUMNS, + [{ reference: "10.1/a", rowIndex: 0, cells: { "Design.type": cell(null) } }], + 1 + ); + expect(toPerspectiveData(explicitNullProjection)[0]["Design.type"]).toBeNull(); + }); + + it("passes scalar cell values (string, number, boolean) through unchanged", () => { + const scalarProjection = new WorkspaceProjection( + COLUMNS, + [ + { reference: "10.1/a", rowIndex: 0, cells: { "Design.type": cell("RCT") } }, + { reference: "10.1/b", rowIndex: 0, cells: { "Design.type": cell(42) } }, + { reference: "10.1/c", rowIndex: 0, cells: { "Design.type": cell(true) } }, + ], + 1 + ); + const rows = toPerspectiveData(scalarProjection); + expect(rows[0]["Design.type"]).toBe("RCT"); + expect(rows[1]["Design.type"]).toBe(42); + expect(rows[2]["Design.type"]).toBe(true); + }); }); describe("cellAt", () => { diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts index 88ab6c8fc..38e95dc04 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts @@ -28,12 +28,38 @@ export function toPerspectiveData(projection: WorkspaceProjection): Record = { [REFERENCE_COLUMN]: row.reference }; for (const column of projection.columns) { const cell = row.cells[column.name]; - record[column.name] = cell ? cell.value : null; + record[column.name] = cell ? toScalarCell(cell.value) : null; } return record; }); } +/** + * Coerces a cell value to something Perspective can build a column schema from. + * + * Perspective infers each column's type from the (assumed-scalar) shape of its cell + * values — it has no notion of an array- or object-typed column. The backend's + * `QuestionType` enum, though, includes `multi_label_selection` (array of labels), + * `ranking` (array of ranked items), and `span` (an object) — all non-scalar. Feeding one + * of those straight into `client.table()` either renders as `[object Object]` or makes the + * whole `table()` call reject (see grid-adapter.test.ts / ExtractionsGrid's `performLoad` + * doc comment for what happens to that rejection). Serializing arrays/objects to a stable + * JSON string keeps every column scalar. `null` is passed through as-is (not the string + * `"null"`) because the manifest-completeness contract in `toPerspectiveData`'s doc comment + * depends on an absent/empty cell being `null`. Genuine scalars (string/number/boolean) are + * passed through unchanged. + */ +function toScalarCell(value: unknown): unknown { + if (value === null || value === undefined) { + return null; + } + const valueType = typeof value; + if (valueType === "string" || valueType === "number" || valueType === "boolean") { + return value; + } + return JSON.stringify(value); +} + /** * Looks up the enriched cell (value + provenance) for a grid position. * From adfb5fdfd0cbdb3bb54a3cd331c54fb93e3e24c2 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 03:12:02 -0700 Subject: [PATCH 15/43] fix(v2-ui): retry the Perspective WASM boot after a transient failure initPerspective() memoized `ready` unconditionally (`ready ??= ...`), including a REJECTED promise: one failed fetch of the server/viewer WASM (offline blip, a 404 right after a redeploy) permanently bricked every later mount of the extractions grid until a hard page reload. Reset the memo to null when the boot rejects so the next call starts a fresh attempt, while still sharing one in-flight promise across concurrent callers and never re-running after a successful boot. --- .../extractions/perspective-bootstrap.test.ts | 91 +++++++++++++++++++ .../v2/extractions/perspective-bootstrap.ts | 35 +++++-- 2 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts new file mode 100644 index 000000000..102ca60d6 --- /dev/null +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * `perspective-bootstrap.ts` is aliased to a stub in `vitest.config.ts` (every OTHER spec + * that mounts `ExtractionsGrid` needs Perspective's WASM/custom-element boot kept out of the + * happy-dom environment entirely — see that alias's comment). This spec exercises the REAL + * module instead, by importing it with a path relative to this file (`./perspective-bootstrap`) + * rather than the `~/components/v2/extractions/perspective-bootstrap` specifier the alias + * matches on. The `@perspective-dev/*` packages themselves are mocked below so importing the + * real module doesn't touch actual WASM/custom-element registration. + */ + +const initServerSpy = vi.fn(); +const initClientSpy = vi.fn(); + +vi.mock("@perspective-dev/client", () => ({ + default: { + init_server: (...args: unknown[]) => initServerSpy(...args), + }, +})); + +vi.mock("@perspective-dev/viewer", () => ({ + default: { + init_client: (...args: unknown[]) => initClientSpy(...args), + }, +})); + +vi.mock("@perspective-dev/viewer-datagrid", () => ({})); + +vi.mock("@perspective-dev/server/dist/wasm/perspective-server.wasm?url", () => ({ default: "server.wasm" })); +vi.mock("@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url", () => ({ default: "viewer.wasm" })); + +beforeEach(() => { + vi.resetModules(); + initServerSpy.mockReset(); + initClientSpy.mockReset(); + // Real `fetch(SERVER_WASM)`/`fetch(CLIENT_WASM)` calls are made with plain mock-string + // "URLs" as arguments (see the wasm `?url` mocks above) regardless of whether + // `init_server`/`init_client` themselves are mocked, since `fetch(...)` is evaluated as an + // argument expression before either is called. Stubbing `fetch` avoids both a real network + // attempt and an unhandled-rejection warning from an un-awaited failed URL parse. + vi.stubGlobal( + "fetch", + vi.fn(async () => ({})) + ); +}); + +describe("initPerspective", () => { + it("resets the memoized boot promise on rejection so the next call retries instead of replaying the same failure", async () => { + initServerSpy.mockImplementationOnce(() => Promise.reject(new Error("network blip"))); + initServerSpy.mockImplementation(() => Promise.resolve(undefined)); + initClientSpy.mockImplementation(() => Promise.resolve(undefined)); + + const { initPerspective } = await import("./perspective-bootstrap"); + + await expect(initPerspective()).rejects.toThrow("network blip"); + // Without the fix, `ready` stays set to the rejected promise forever (`ready ??= ...` + // never reassigns once `ready` is non-null, rejected or not), so this second call would + // reject with the SAME "network blip" error instead of resolving. + await expect(initPerspective()).resolves.toBeDefined(); + expect(initServerSpy).toHaveBeenCalledTimes(2); + }); + + it("propagates the rejection to every caller sharing the failed in-flight attempt", async () => { + initServerSpy.mockImplementation(() => Promise.reject(new Error("network blip"))); + initClientSpy.mockImplementation(() => Promise.resolve(undefined)); + + const { initPerspective } = await import("./perspective-bootstrap"); + + const [a, b] = await Promise.allSettled([initPerspective(), initPerspective()]); + expect(a.status).toBe("rejected"); + expect(b.status).toBe("rejected"); + // Both callers raced the SAME attempt, so only one underlying boot should have run. + expect(initServerSpy).toHaveBeenCalledTimes(1); + }); + + it("initializes exactly once across concurrent and sequential calls on a successful boot", async () => { + initServerSpy.mockImplementation(() => Promise.resolve(undefined)); + initClientSpy.mockImplementation(() => Promise.resolve(undefined)); + + const { initPerspective } = await import("./perspective-bootstrap"); + + const [a, b] = await Promise.all([initPerspective(), initPerspective()]); + const c = await initPerspective(); + + expect(a).toBe(b); + expect(b).toBe(c); + expect(initServerSpy).toHaveBeenCalledTimes(1); + expect(initClientSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts index da891904e..62350e1a9 100644 --- a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts @@ -4,14 +4,35 @@ import "@perspective-dev/viewer-datagrid"; import SERVER_WASM from "@perspective-dev/server/dist/wasm/perspective-server.wasm?url"; import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wasm?url"; -// SPA (ssr: false): this runs client-side only. Module-level guard so the WASM -// engines initialize exactly once no matter how often the page remounts (spec §3.3). +// SPA (ssr: false): this runs client-side only. Module-level guard so the WASM engines +// initialize exactly once no matter how often the page remounts (spec §3.3). +// +// `ready` memoizes the boot promise so concurrent callers (e.g. two fast remounts) share one +// in-flight WASM init instead of racing multiple `init_server`/`init_client` calls. If that +// boot rejects (a transient `fetch(SERVER_WASM)` blip — offline, or a 404 right after a +// redeploy), `ready` is reset back to `null` so the *next* call starts a fresh attempt +// instead of replaying the same rejection forever: without the reset, one failed boot would +// permanently brick every later mount until a hard page reload. A *resolved* boot is never +// reset, so the success path still only ever runs once. The `.catch` below only resets the +// module-level memo — it does not swallow the rejection for the caller, who still receives +// it via the `ready` promise returned below. let ready: Promise | null = null; -export const initPerspective = () => { - ready ??= Promise.all([ - perspective.init_server(fetch(SERVER_WASM)), - perspective_viewer.init_client(fetch(CLIENT_WASM)), - ]).then(() => perspective); +export const initPerspective = (): Promise => { + if (!ready) { + const attempt: Promise = Promise.all([ + perspective.init_server(fetch(SERVER_WASM)), + perspective_viewer.init_client(fetch(CLIENT_WASM)), + ]).then(() => perspective); + attempt.catch(() => { + // Only clear the memo if nothing has raced in a newer attempt already (can't happen + // in practice — a new attempt is only created when `ready` is falsy — but guards + // against clobbering a fresher attempt if this ever changes). + if (ready === attempt) { + ready = null; + } + }); + ready = attempt; + } return ready; }; From 146f5ff43e10fb6e8d001ad3a5949e2aa399d26a Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 03:12:53 -0700 Subject: [PATCH 16/43] fix(v2-ui): share one Perspective Client/Worker across ExtractionsGrid mounts ExtractionsGrid called perspective.worker() directly on every mount. worker() constructs a brand-new Web Worker + WASM server instance each call (confirmed in @perspective-dev/client's worker()/pe() helpers); only Client.terminate() runs the close callback that tears one down, and onBeforeUnmount never called it. Ten visits to /extractions left ten live workers, each with a full WASM heap. Hoist the client into a module-level memo (initPerspectiveClient) alongside the existing WASM-boot memo, mirroring its retry-on-rejection behavior. Exactly one client/worker now lives for the app's session; ExtractionsGrid only drops its local reference on unmount and intentionally never terminates the shared client. --- .../__mocks__/perspective-bootstrap.js | 20 +++++++---- .../ExtractionsGrid.client.test.ts | 4 +-- .../v2/extractions/ExtractionsGrid.client.vue | 19 ++++++---- .../extractions/perspective-bootstrap.test.ts | 36 +++++++++++++++++++ .../v2/extractions/perspective-bootstrap.ts | 27 ++++++++++++++ 5 files changed, 90 insertions(+), 16 deletions(-) diff --git a/extralit-frontend/__mocks__/perspective-bootstrap.js b/extralit-frontend/__mocks__/perspective-bootstrap.js index 6fd46cbe6..0a72ca383 100644 --- a/extralit-frontend/__mocks__/perspective-bootstrap.js +++ b/extralit-frontend/__mocks__/perspective-bootstrap.js @@ -1,11 +1,17 @@ // Perspective touches WASM + custom elements at import time; specs use this stub // (same rationale as __mocks__/tabulator-tables.js). -export const initPerspective = async () => ({ - worker: async () => ({ - table: async (data) => ({ - __data: data, - size: async () => data.length, - delete: async () => undefined, - }), +const stubClient = { + table: async (data) => ({ + __data: data, + size: async () => data.length, + delete: async () => undefined, }), +}; + +export const initPerspective = async () => ({ + worker: async () => stubClient, }); + +// Mirrors the real module's hoisted-client memo (`initPerspectiveClient`): components should +// call this instead of `(await initPerspective()).worker()` so only one stub client exists. +export const initPerspectiveClient = async () => stubClient; diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts index 06d27fd75..9083f0c0a 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts @@ -4,10 +4,10 @@ import ExtractionsGrid from "./ExtractionsGrid.client.vue"; import { WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; const tableSpy = vi.fn(async (data: unknown) => ({ __data: data, delete: async () => undefined })); -const initSpy = vi.fn(async () => ({ worker: async () => ({ table: tableSpy }) })); +const initSpy = vi.fn(async () => ({ table: tableSpy })); vi.mock("~/components/v2/extractions/perspective-bootstrap", () => ({ - initPerspective: () => initSpy(), + initPerspectiveClient: () => initSpy(), })); const PROJECTION = new WorkspaceProjection( diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 46688115c..5739ae298 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -5,7 +5,7 @@ diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts index 102ca60d6..c420ed6b3 100644 --- a/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts @@ -12,10 +12,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const initServerSpy = vi.fn(); const initClientSpy = vi.fn(); +const workerSpy = vi.fn(); vi.mock("@perspective-dev/client", () => ({ default: { init_server: (...args: unknown[]) => initServerSpy(...args), + worker: (...args: unknown[]) => workerSpy(...args), }, })); @@ -34,6 +36,7 @@ beforeEach(() => { vi.resetModules(); initServerSpy.mockReset(); initClientSpy.mockReset(); + workerSpy.mockReset(); // Real `fetch(SERVER_WASM)`/`fetch(CLIENT_WASM)` calls are made with plain mock-string // "URLs" as arguments (see the wasm `?url` mocks above) regardless of whether // `init_server`/`init_client` themselves are mocked, since `fetch(...)` is evaluated as an @@ -89,3 +92,36 @@ describe("initPerspective", () => { expect(initClientSpy).toHaveBeenCalledTimes(1); }); }); + +describe("initPerspectiveClient", () => { + it("resets the memoized client promise on rejection so the next call retries instead of replaying the same failure", async () => { + initServerSpy.mockImplementation(() => Promise.resolve(undefined)); + initClientSpy.mockImplementation(() => Promise.resolve(undefined)); + workerSpy.mockImplementationOnce(() => Promise.reject(new Error("worker boot failed"))); + workerSpy.mockImplementation(() => Promise.resolve({ marker: "client" })); + + const { initPerspectiveClient } = await import("./perspective-bootstrap"); + + await expect(initPerspectiveClient()).rejects.toThrow("worker boot failed"); + await expect(initPerspectiveClient()).resolves.toEqual({ marker: "client" }); + expect(workerSpy).toHaveBeenCalledTimes(2); + }); + + it("shares exactly one client across concurrent and sequential calls, calling perspective.worker() exactly once", async () => { + initServerSpy.mockImplementation(() => Promise.resolve(undefined)); + initClientSpy.mockImplementation(() => Promise.resolve(undefined)); + workerSpy.mockImplementation(() => Promise.resolve({ marker: "client" })); + + const { initPerspectiveClient } = await import("./perspective-bootstrap"); + + const [a, b] = await Promise.all([initPerspectiveClient(), initPerspectiveClient()]); + const c = await initPerspectiveClient(); + + expect(a).toBe(b); + expect(b).toBe(c); + // This is the crux of the worker/WASM-heap leak fix: every mount of `ExtractionsGrid` + // must share this ONE client rather than each calling `perspective.worker()` (which + // constructs a brand-new Web Worker + WASM server instance every time) for itself. + expect(workerSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts index 62350e1a9..583e0cdbc 100644 --- a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts @@ -36,3 +36,30 @@ export const initPerspective = (): Promise => { } return ready; }; + +type PerspectiveClient = Awaited>; + +// `perspective.worker()` constructs a brand-new Web Worker (+ its own WASM server +// instance) on *every* call — confirmed in +// `@perspective-dev/client/dist/esm/perspective.js`'s `worker()`/`pe()` helpers, which +// always `new Worker(...)`. Only `Client.terminate()` +// (`@perspective-dev/client/dist/wasm/perspective-js.d.ts`'s `Client.terminate()`) runs the +// close callback that actually calls `Worker.terminate()`. Memoizing the client here — +// instead of every `ExtractionsGrid` mount calling `perspective.worker()` for itself — means +// exactly one worker/WASM heap is ever live for the app's session, no matter how many times +// `/extractions` is mounted and unmounted. Mirrors `ready`'s retry-on-rejection behavior for +// the same reason: a transient worker-boot failure must not brick every later mount. +let clientReady: Promise | null = null; + +export const initPerspectiveClient = (): Promise => { + if (!clientReady) { + const attempt = initPerspective().then((p) => p.worker()); + attempt.catch(() => { + if (clientReady === attempt) { + clientReady = null; + } + }); + clientReady = attempt; + } + return clientReady; +}; From 92696ffeee19d6877f409a463cb5acb8384cfca1 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 03:37:50 -0700 Subject: [PATCH 17/43] fix(v2-ui): ignore load-error from a superseded extractions projection --- .../ExtractionsGrid.client.test.ts | 69 +++++++++++++++++++ .../v2/extractions/ExtractionsGrid.client.vue | 10 ++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts index 9083f0c0a..0d2b66439 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts @@ -33,6 +33,43 @@ const PROJECTION = new WorkspaceProjection( 1 ); +// A distinct instance (referential inequality from PROJECTION matters — see the superseded-load +// test below) representing the workspace the user switches to mid-load. +const PROJECTION_2 = new WorkspaceProjection( + [ + { + name: "Design.type", + schemaId: "s-2", + schemaName: "Design", + questionName: "type", + subColumn: null, + dtype: "text", + }, + ], + [ + { + reference: "10.2/b", + rowIndex: 0, + cells: { + "Design.type": { value: "Cohort", source: "response", recordId: "r-2", agent: null, score: null }, + }, + }, + ], + 1 +); + +// Deferred promise helper — lets this suite control settlement order deterministically (mirrors +// the identical helper in useExtractionsViewModel.test.ts). +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + describe("ExtractionsGrid", () => { it("boots perspective once and loads the flat projection rows into a table", async () => { mount(ExtractionsGrid, { @@ -63,4 +100,36 @@ describe("ExtractionsGrid", () => { consoleErrorSpy.mockRestore(); }); + + it("does not emit load-error for a superseded projection whose table build rejects after a newer one is already current", async () => { + // Reproduces the stale-load race: P1's `client.table()` is still in flight when the user + // switches workspace to P2, then P1 rejects *after* P2 is already `props.projection`. The + // grid must not surface P1's failure — the page would otherwise latch `loadFailed = true` + // for a workspace (P2) that never actually failed, and never clear it (see this file's + // `load-error` doc comment). + const firstCall = deferred>>(); + tableSpy.mockImplementationOnce(() => firstCall.promise); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const wrapper = mount(ExtractionsGrid, { + props: { projection: PROJECTION }, + global: { + config: { compilerOptions: { isCustomElement: (tag: string) => tag.startsWith("perspective-") } }, + }, + }); + await flushPromises(); + + // Supersede P1 with P2 before P1's table build settles. + await wrapper.setProps({ projection: PROJECTION_2 }); + await flushPromises(); + + // Now P1's stale build rejects. + firstCall.reject(new Error("boom")); + await flushPromises(); + + expect(wrapper.emitted("load-error")).toBeUndefined(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); }); diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 5739ae298..ab6746021 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -225,7 +225,15 @@ async function performLoad(projection: WorkspaceProjection): Promise { // failure with no explanation ever shown to the user. newTable = await client.table(toPerspectiveData(projection)); } catch (error) { - if (!cancelled) { + // Guards against a stale load's failure clobbering a newer, healthy projection: `loadChain` + // serializes *starts*, not *settlements* — an earlier-queued `performLoad(P1)` can still be + // awaiting a rejecting `client.table()` after `performLoad(P2)` has already been requested + // (and, since Vue updates `props.projection` synchronously before the watcher fires, has + // already become the current `props.projection`). Without this check, P1's rejection would + // still emit `load-error`, which the page latches into a permanent `loadFailed` state that + // outlives P2's perfectly healthy load — see this file's `load-error` doc comment and the + // fix's changelog entry for the full failure sequence. + if (!cancelled && projection === props.projection) { console.error("[ExtractionsGrid] failed to build the Perspective table for this projection", error); emit("load-error"); } From 08db36722ce6e6038b6247dbe021ba88c6875666 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Wed, 22 Jul 2026 15:00:51 -0700 Subject: [PATCH 18/43] fix(v2-ui): render ExtractionsGrid banding and cursor, and report viewer load failures The datagrid plugin picks `renderTarget = CSS.supports("selector(:host-context(foo))") ? "shadow" : "light"` and, in the shadow case, renders the regular-table and every inside an attachShadow({mode:"open"}) root. Chromium takes that branch, so the Vue scoped `:deep()` rules -- which compile to a document-level stylesheet -- could not reach the cells: banding and the pointer cursor were toggled on every draw and styled nothing. Inject an id-guarded diff --git a/extralit-frontend/pages/extractions/index.vue b/extralit-frontend/pages/extractions/index.vue index 6a6d0bfbd..ee39b9da3 100644 --- a/extralit-frontend/pages/extractions/index.vue +++ b/extralit-frontend/pages/extractions/index.vue @@ -12,7 +12,7 @@ - +
diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts index 15ef792aa..47d8d73af 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -105,12 +105,6 @@ describe("useExtractionsViewModel", () => { expect(vm.loadFailed.value).toBe(true); }); - it("builds the annotation URL on cell click but does not navigate (guard off)", () => { - const vm = useExtractionsViewModel("w-1"); - const url = vm.onCellClick({ schemaId: "s-1", reference: "10.1/a b" }); - expect(url).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); - }); - it("flags load failure when the grid reports a load-error after a successful projection load", async () => { // Reproduces ExtractionsGrid's `load-error` emit (e.g. `client.table()` rejecting on a // non-scalar cell value): the projection itself loaded fine, but the page's state diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts index be930727d..3d80d1cfc 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -2,7 +2,6 @@ import { computed, ref, shallowRef, watch } from "vue"; import { useResolve } from "ts-injecty"; import { GetWorkspaceProjectionUseCase } from "~/v2/domain/usecases/get-workspace-projection-use-case"; import { type WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; -import { buildAnnotationUrl, ANNOTATION_CELL_LINKS_ENABLED } from "~/v2/domain/entities/projection/grid-adapter"; // Documented v1 exception: workspace selection survives Phase 6 (see plan Global Constraints). import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; @@ -86,15 +85,6 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => watch(workspaceId, load); - const onCellClick = ({ schemaId, reference }: { schemaId: string; reference: string }): string => { - const url = buildAnnotationUrl(schemaId, reference); - if (ANNOTATION_CELL_LINKS_ENABLED) { - // Guarded off: annotation-mode cannot resolve v2 schema ids yet (see grid-adapter.ts). - window.location.href = url; - } - return url; - }; - // Wired to `ExtractionsGrid`'s `load-error` emit (see its doc comment): a rejection while // building/loading the Perspective table for the current projection is surfaced through // this same `loadFailed` flag the page already renders `extractions.loadError` for, so the @@ -103,5 +93,5 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => loadFailed.value = true; }; - return { projection, isLoading, loadFailed, hasLoaded, workspaceId, load, onCellClick, onGridLoadError }; + return { projection, isLoading, loadFailed, hasLoaded, workspaceId, load, onGridLoadError }; }; diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts index cd9ee5dc0..ee89e10de 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts @@ -1,12 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - ANNOTATION_CELL_LINKS_ENABLED, - bandParity, - buildAnnotationUrl, - cellAt, - REFERENCE_COLUMN, - toPerspectiveData, -} from "./grid-adapter"; +import { bandParity, REFERENCE_COLUMN, toPerspectiveData } from "./grid-adapter"; import { WorkspaceProjection, type ProjectionGridCell } from "./WorkspaceProjection"; const cell = (value: unknown): ProjectionGridCell => ({ @@ -164,17 +157,6 @@ describe("toPerspectiveData", () => { }); }); -describe("cellAt", () => { - it("returns the enriched cell for the loaded row order", () => { - expect(cellAt(PROJECTION, 0, "Design.type")?.recordId).toBe("r-1"); - }); - - it("returns null for empty cells and out-of-range rows", () => { - expect(cellAt(PROJECTION, 2, "Design.type")).toBeNull(); - expect(cellAt(PROJECTION, 99, "Design.type")).toBeNull(); - }); -}); - describe("bandParity", () => { it("flips parity when the reference changes, not per row", () => { expect(bandParity(PROJECTION)).toEqual([0, 0, 1]); @@ -198,21 +180,3 @@ describe("bandParity", () => { expect(bandParity(empty)).toEqual([]); }); }); - -describe("annotation URL contract (spec §3.3)", () => { - it("stays guarded off in this build", () => { - expect(ANNOTATION_CELL_LINKS_ENABLED).toBe(false); - }); - - it("puts the schema id in the dataset slot and encodes the reference", () => { - expect(buildAnnotationUrl("s-1", "10.1/a b")).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); - }); - - it("encodes reserved query characters (&, #) in the reference", () => { - expect(buildAnnotationUrl("s-1", "a&b#c")).toBe("/dataset/s-1/annotation-mode?_search=a%26b%23c"); - }); - - it("encodes reserved characters in the schema id path segment too", () => { - expect(buildAnnotationUrl("s/1", "10.1/a")).toBe("/dataset/s%2F1/annotation-mode?_search=10.1%2Fa"); - }); -}); diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts index bf89039b8..9de494beb 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts @@ -1,4 +1,4 @@ -import { type WorkspaceProjection, type ProjectionGridCell } from "./WorkspaceProjection"; +import { type WorkspaceProjection } from "./WorkspaceProjection"; /** * Flat-row column name carrying the reference (DOI/identifier) for each row. @@ -72,25 +72,6 @@ function toScalarCell(value: unknown): unknown { } } -/** - * Looks up the enriched cell (value + provenance) for a grid position. - * - * Relies on the static-grid invariant: the projection is loaded once, unsorted and - * unfiltered, so a datagrid row index maps 1:1 onto `projection.rows` by array position. - * Returns null for an absent cell or an out-of-range row index. - */ -export function cellAt( - projection: WorkspaceProjection, - rowIndex: number, - columnName: string -): ProjectionGridCell | null { - const row = projection.rows[rowIndex]; - if (!row) { - return null; - } - return row.cells[columnName] ?? null; -} - /** * Returns a 0/1 parity value per row that flips whenever `reference` changes from the * previous row (not per row). Perspective has no notion of merged/grouped cells, so this @@ -107,18 +88,3 @@ export function bandParity(projection: WorkspaceProjection): number[] { return parity; }); } - -/** - * Guards the §3.3 click-to-annotate affordance off until annotation-mode resolves v2 schema - * ids (see ledger §5). Flip to true once that lands. - */ -export const ANNOTATION_CELL_LINKS_ENABLED = false; - -/** - * Builds the annotation-mode deep link for a cell's reference, percent-encoding both the - * schema id (path segment) and the reference (query value) so slashes, `&`, `#`, and other - * reserved characters can't reshape the URL. - */ -export function buildAnnotationUrl(schemaId: string, reference: string): string { - return `/dataset/${encodeURIComponent(schemaId)}/annotation-mode?_search=${encodeURIComponent(reference)}`; -} From ac3f04e4a2e34c1deec61288a575c2b520b611ee Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Fri, 24 Jul 2026 18:34:36 -0700 Subject: [PATCH 26/43] chore --- .claude/commands/refractor.md | 17 ---- .claude/commands/update-claudemd.md | 144 ---------------------------- 2 files changed, 161 deletions(-) delete mode 100644 .claude/commands/refractor.md delete mode 100644 .claude/commands/update-claudemd.md diff --git a/.claude/commands/refractor.md b/.claude/commands/refractor.md deleted file mode 100644 index 6b8496272..000000000 --- a/.claude/commands/refractor.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -description: Refactor code following best practices and design patterns ---- - -## Your task - -Refactor the following code: @$ARGUMENTS - -Guidelines: -1. **Maintain functionality**: Ensure no breaking changes -2. **Improve readability**: Make code more self-documenting -3. **Extract common patterns**: Identify and extract reusable components -4. **Performance optimization**: Improve efficiency where possible -5. **Modern conventions**: Use current best practices -6. **Type safety**: Add or improve type annotations if applicable - -Explain each change and why it's beneficial. \ No newline at end of file diff --git a/.claude/commands/update-claudemd.md b/.claude/commands/update-claudemd.md deleted file mode 100644 index f38b9f223..000000000 --- a/.claude/commands/update-claudemd.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -allowed-tools: Bash(git diff:*), Bash(git log:*), Bash(git status:*), Bash(find:*), Bash(grep:*), Bash(wc:*), Bash(ls:*) -description: Automatically update CLAUDE.md file based on recent code changes ---- - -# Update Claude.md File - -## Current Claude.md State -@CLAUDE.md - -## Git Analysis - -### Current Repository Status -!`git status --porcelain` - -### Recent Changes (Last 10 commits) -!`git log --oneline -10` - -### Detailed Recent Changes -!`git log --since="1 week ago" --pretty=format:"%h - %an, %ar : %s" --stat` - -### Recent Diff Analysis -!`git diff HEAD~5 --name-only | head -20` - -### Detailed Diff of Key Changes -!`git diff HEAD~5 -- "*.js" "*.ts" "*.jsx" "*.tsx" "*.py" "*.md" "*.json" | head -200` - -### New Files Added -!`git diff --name-status HEAD~10 | grep "^A" | head -15` - -### Deleted Files -!`git diff --name-status HEAD~10 | grep "^D" | head -10` - -### Modified Core Files -!`git diff --name-status HEAD~10 | grep "^M" | grep -E "(package\.json|README|config|main|index|app)" | head -10` - -## Project Structure Changes -!`find . -name "*.md" -not -path "./node_modules/*" -not -path "./.git/*" | head -10` - -## Configuration Changes -!`git diff HEAD~10 -- package.json tsconfig.json webpack.config.js next.config.js .env* docker* | head -100` - -## API/Route Changes -!`git diff HEAD~10 -- "**/routes/**" "**/api/**" "**/controllers/**" | head -150` - -## Database/Model Changes -!`git diff HEAD~10 -- "**/models/**" "**/schemas/**" "**/migrations/**" | head -100` - -## Your Task - -Based on the current CLAUDE.md content and all the git analysis above, create an updated CLAUDE.md file that: - -## 1. Preserves Important Existing Content -- Keep the core project description and architecture -- Maintain important setup instructions -- Preserve key architectural decisions and patterns -- Keep essential development workflow information - -## 2. Integrates Recent Changes -Analyze the git diff and logs to identify: -- **New Features**: What new functionality was added? -- **API Changes**: New endpoints, modified routes, updated parameters -- **Configuration Updates**: Changes to build tools, dependencies, environment variables -- **File Structure Changes**: New directories, moved files, deleted components -- **Database Changes**: New models, schema updates, migrations -- **Bug Fixes**: Important fixes that affect how the system works -- **Refactoring**: Significant code reorganization or architectural changes - -## 3. Updates Key Sections -Intelligently update these CLAUDE.md sections: - -### Project Overview -- Update description if scope changed -- Note new technologies or frameworks added -- Update version information - -### Architecture -- Document new architectural patterns -- Note significant structural changes -- Update component relationships - -### Setup Instructions -- Add new environment variables -- Update installation steps if dependencies changed -- Note new configuration requirements - -### API Documentation -- Add new endpoints discovered in routes -- Update existing endpoint documentation -- Note authentication or parameter changes - -### Development Workflow -- Update based on new scripts in package.json -- Note new development tools or processes -- Update testing procedures if changed - -### Recent Changes Section -Add a "Recent Updates" section with: -- Summary of major changes from git analysis -- New features and their impact -- Important bug fixes -- Breaking changes developers should know about - -### File Structure -- Update directory explanations for new folders -- Note relocated or reorganized files -- Document new important files - -## 4. Smart Content Management -- **Don't duplicate**: Avoid repeating information already well-documented -- **Prioritize relevance**: Focus on changes that affect how developers work with the code -- **Keep it concise**: Summarize rather than listing every small change -- **Maintain structure**: Follow existing CLAUDE.md organization -- **Add timestamps**: Note when major updates were made - -## 5. Output Format -Provide the complete updated CLAUDE.md content, organized as: - -```markdown -# Project Name - -## Overview -[Updated project description] - -## Architecture -[Updated architecture information] - -## Setup & Installation -[Updated setup instructions] - -## Development Workflow -[Updated development processes] - -## API Documentation -[Updated API information] - -## File Structure -[Updated directory explanations] - -## Recent Updates (Updated: YYYY-MM-DD) -[Summary of recent changes] - -## Important Notes -[Key information for developers] \ No newline at end of file From b72fbe32078be055bae3429f807f9c00c683f4b6 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 25 Jul 2026 01:12:36 -0700 Subject: [PATCH 27/43] feat(docs): add acceptance criteria for extraction projection viewer Introduced a new document detailing acceptance criteria for the extraction projection viewer, outlining specific tests for grid behavior, data handling, and performance under real data conditions. Updated the existing extraction table design document to reference these criteria, ensuring comprehensive coverage of the testing strategy. --- .../2026-07-20-extraction-table-design.md | 8 + ...-07-24-extraction-projection-acceptance.md | 180 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-extraction-projection-acceptance.md diff --git a/docs/superpowers/specs/2026-07-20-extraction-table-design.md b/docs/superpowers/specs/2026-07-20-extraction-table-design.md index 84f8e3850..fb0a5350a 100644 --- a/docs/superpowers/specs/2026-07-20-extraction-table-design.md +++ b/docs/superpowers/specs/2026-07-20-extraction-table-design.md @@ -193,6 +193,14 @@ tests). Replacement gate in this build: a new `e2e/v2/extractions-grid.spec.ts` ## 4. Testing strategy +**Acceptance criteria:** the properties these gates exist to prove are enumerated in +[`2026-07-24-extraction-projection-acceptance.md`](./2026-07-24-extraction-projection-acceptance.md) +(AC1 coverage map, AC2 coalescing + provenance, AC3 fan-out without fabricated joins, +AC4 banding + static grid, AC5 real-data scale + loud failure). Each criterion names its +owning gate. AC4 and AC5's scale half are browser-only — happy-dom never upgrades the +Perspective custom element — which is why that document also specifies the real-data +itn-recal seed the Playwright gate runs against. + - **Server unit/integration:** denormalization (coalesce precedence, table fan-out, independent stacking, scalar repetition, empty-cell omission), row universe (union of references, refs with zero records in a schema), pagination diff --git a/docs/superpowers/specs/2026-07-24-extraction-projection-acceptance.md b/docs/superpowers/specs/2026-07-24-extraction-projection-acceptance.md new file mode 100644 index 000000000..7372d9c42 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-extraction-projection-acceptance.md @@ -0,0 +1,180 @@ +# Extraction Projection Viewer — Acceptance Criteria + +**Date:** 2026-07-24 +**Branch:** `feat/v2-ui-extraction-grid` +**Derives from:** `2026-07-20-extraction-table-design.md` §3.1–§3.4 (grid semantics, +backend projection, frontend page, table-value fan-out) and §4 (testing strategy). + +## Why this document exists + +PRs #233 (server projection endpoint + data layer) and #234 (`/extractions` Perspective +grid) were built against **synthetic fixtures only** — invented studies in +`extralit-frontend/demo/seed_demo_workspace.py` plus a single-reference e2e seed. Nothing +has exercised the viewer against real extraction data, and the unit suite structurally +cannot see the Perspective lifecycle at all (happy-dom never upgrades the custom element). + +A real-data seed is being built from `itn-recal_postgresql_v16_08-07-2025.dump` — 58 papers +× 4 table schemas plus flat `Publication` records. These criteria are written **first**, so +that the seed is built against a stated target rather than retrofitted to whatever it +happens to produce. + +Each criterion names the gate that owns it: + +| Tag | Gate | +|---|---| +| **[srv]** | `extralit-server` pytest | +| **[unit]** | `extralit-frontend` vitest | +| **[e2e]** | Playwright against a live stack + the itn-recal seed | + +--- + +## AC1 — The grid is a complete coverage map + +Rows are the union of every distinct reference across all schemas in the workspace; columns +are every question of every schema (`Schema.question[.subcol]`), **including schemas with +zero records**. A missing (reference, schema) pair renders as empty cells — never as an +absent row or a dropped column. + +- **Given** schema A has records for refs r1,r2 and schema B only for r2, **when** the + viewer loads, **then** rows exist for both r1 and r2 and B's columns render empty on + r1. *[srv, e2e]* +- **Given** a schema with questions but zero records, **when** the viewer loads, **then** + its columns still appear in the manifest with every cell empty. *[srv, e2e]* +- **Given** N distinct references spanning more than one page of the endpoint, **when** the + viewer finishes loading, **then** all N references are displayed and `total_references` + equals N. *[srv, unit]* + +## AC2 — Every cell shows the winning value, with its provenance + +Precedence is latest **submitted** response `??` suggestion `??` empty, applied server-side. +Drafts never appear. Cells carry `record_id` + `source` (+ `agent`/`score` for suggestions). + +Note the deliberate **record-level** semantic (`contexts/v2/projection.py:166-175`): the +latest submitted *envelope* wins outright; a question absent from that envelope falls back +to its suggestion even when an earlier submitted response answered it. Envelopes are not +merged per cell. + +- **Given** a question with both a suggestion and a differing submitted response, **when** + the viewer loads, **then** the response value renders and the suggestion value appears + nowhere. *[srv, e2e]* +- **Given** a question with only a suggestion, **when** the viewer loads, **then** the + suggestion renders and the cell reports `source: "suggestion"` with its agent/score. + *[srv, unit]* +- **Given** a **draft** (non-submitted) response, **when** the viewer loads, **then** the + draft value never renders — the suggestion or empty shows instead. *[srv, e2e]* +- **Given** two users have submitted responses to the same record, **when** the viewer + loads, **then** the latest-updated envelope's values render. *[srv]* +- **Given** raw `record.fields` values that differ from the suggestion/response values, + **when** the viewer loads, **then** no raw field value appears anywhere in the grid. + *[e2e — this is the assertion that caught the hollow gate in `9ab8d5242`]* + +## AC3 — Table questions fan out into stacked rows without fabricating joins + +Per-reference row count = max fan-out across all its table questions, minimum 1. Scalars +repeat identically on every fan-out row. No cartesian product; shorter tables leave empty +cells on trailing rows. Cross-schema row alignment is **positional only and carries no +meaning** — join keys are ordinary cell values. + +- **Given** a reference with a 3-row table question plus scalar questions, **when** the + viewer loads, **then** it occupies 3 rows and each scalar repeats identically on all 3. + *[srv, e2e]* +- **Given** table question X with 2 rows and table question Y with 5 rows on one reference, + **when** the viewer loads, **then** the reference occupies 5 rows (not 10) and X's + columns are empty on rows 3–5. *[srv]* +- **Given** a table value delivered as a bare dict rather than a list, **when** the viewer + loads, **then** it fans out to exactly 1 row. *[srv]* +- **Given** two schemas related by a join key (`observation_ref` → `Observation.reference`), + **when** the viewer loads, **then** the join key renders as an ordinary readable cell and + no assertion is made about cross-schema row correspondence. *[e2e]* + +## AC4 — Reference grouping is legible and the grid is static + +Row-banding flips on reference change (Perspective is flat-columnar and cannot merge +cells); toolbar/settings hidden; no sort or filter affordance in v1. + +- **Given** consecutive references each fanning out to several rows, **when** rendered in a + real browser, **then** all rows of one reference share a background tint and adjacent + references differ — asserted on **computed style**, not class presence. *[e2e]* +- **Given** the viewer has loaded, **when** the surface is inspected, **then** no toolbar, + settings, sort or filter control is *usable* — asserted on visibility, not DOM presence. + Perspective does not remove `#settings_button` under `settings: false`; it collapses it to + 0×0 while leaving `display: flex; visibility: visible` (verified in-browser 2026-07-24), so + a DOM-absence assertion fails against a correctly static grid. *[e2e]* +- **Given** one reference occupying many rows, **when** rendered, **then** the reference + value repeats on every row rather than being merged. *[unit, e2e]* + +## AC5 — The viewer survives real data at scale and fails loudly + +Scale target is ~100s of references × ~5 schemas × ~30 columns loaded into one Perspective +table. Non-scalar values must not blank the grid; failures must surface rather than +presenting as an empty or perpetually-loading grid. + +- **Given** the full itn-recal seed (58 references × 5 schemas, ~70 columns), **when** the + viewer loads, **then** the grid paints and every manifest column is present on every row. + *[e2e]* +- **Given** a cell value that is an array or object (`multi_label_selection`, `ranking`, + `span`), **when** the viewer loads, **then** it renders as a stable stringified value and + the `client.table()` call does not reject. *[unit]* +- **Given** the projection request fails, **when** the viewer loads, **then** an explicit + load-error state is shown — not an indefinite spinner or a silently blank grid. *[unit]* +- **Given** the user switches workspace mid-load, **when** the superseded response arrives, + **then** it is discarded and the grid shows the selected workspace. *[unit]* + +--- + +## Coverage note + +AC1–AC3 are largely provable today by `[srv]`. AC4 and the scale half of AC5 are provable +**only in a browser** — happy-dom never upgrades the `` custom element, +so vitest cannot observe the Perspective lifecycle at all. That is precisely the gap the +itn-recal seed closes. + +## The seed the browser-only gates run against + +Built outside this repo (`~/Projects/Extralit/data/`), loaded through the **v2 HTTP API** +rather than direct SQLAlchemy inserts so that `QuestionBindingValidator` and schema +publication gates are genuinely exercised, then snapshotted as a SQLite file. + +| Legacy (dump) | v2 | +|---|---| +| workspace `itn-recalibration` | workspace, same name | +| `data/schemas/*.json` (pandera) | `Schema` + `SchemaVersion.body` | +| `records.metadata->>'type'` | which `Schema` the record belongs to | +| `records.metadata->>'reference'` (bibtex key) | `V2Record.reference` | +| `fields.extraction` (LLM, `to_json(orient="table")`) | **suggestion**, `agent="gpt-3.5-turbo"` | +| `responses.extraction-correction` | **response** on the same question | +| `data: [{...row}, …]` | table-question value = `list[dict]` → fan-out | + +Mapping the LLM blob to a suggestion and the human correction to a response is what makes +AC2's coalescing actually exercised, and it matches the source semantics exactly. +`record.fields` is never projected, so it carries a minimal provenance stub — deliberately +**distinct** from the annotation values, which is what AC2's last criterion asserts against. + +Shapes covered: + +- **Publication** — flat, one scalar question per column (proves AC1, AC2). +- **Observation**, **ITNCondition** — table, one `table` question bound to data columns + **plus `reference`** (proves AC3 fan-out, stacking, scalar repetition). +- **ClinicalOutcome**, **EntomologicalOutcome** — table, bound to data columns **plus + `observation_ref` and `itncondition_ref`** (proves AC3's join-keys-as-columns criterion + and AC5's scale criterion). + +**Non-obvious transform:** pandera declares `reference` / `observation_ref` / +`itncondition_ref` under `index`, not `columns`. `columns_cache` derives only from pandera +`columns`, and questions can only bind what is in `columns_cache`. The transform therefore +**promotes index entries into `columns`** in the SchemaVersion body. Without that, the join +keys are unbindable and invisible, and AC3's last criterion cannot pass. + +**Cleaning is auditable, not asserted:** unparseable `extraction` blobs are dropped, +`""`/`"NA"`/`"n/a"` become null, all scalars are stringified; ~3% dangling +`observation_ref`/`itncondition_ref` are **kept** — real mess is the point. A +`transform-report.json` records counts of everything dropped or coerced. + +## What the grid will honestly look like + +Fan-out is global across all five schemas, so a paper with 30 `ClinicalOutcome` rows and 1 +`Publication` row yields 30 grid rows with `Publication` repeated and `Observation` blank +past its own row count. Five schemas × ~70 columns is a very wide, sparse grid with no sort, +filter, or column visibility. Join keys are readable as cells; the *positional* alignment +between schemas is meaningless — and that will be visible. AC3's last criterion asserts +exactly this rather than papering over it. From dad64f950558e84ae2ebf6a9530636feb207458f Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 25 Jul 2026 01:12:44 -0700 Subject: [PATCH 28/43] Revert "refactor(v2-ui): drop the extractions cell-click plumbing, keep row banding" This reverts commit dfc0e357ba6dfb395ebf22254f08791aef861679. --- .../v2/extractions/ExtractionsGrid.client.vue | 105 +++++++++++++----- extralit-frontend/pages/extractions/index.vue | 2 +- .../useExtractionsViewModel.test.ts | 6 + .../extractions/useExtractionsViewModel.ts | 12 +- .../entities/projection/grid-adapter.test.ts | 38 ++++++- .../entities/projection/grid-adapter.ts | 36 +++++- 6 files changed, 165 insertions(+), 34 deletions(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 87dc0c6fe..14abce775 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -6,8 +6,8 @@ import { onBeforeUnmount, onMounted, ref, watch } from "vue"; import { type HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; import { initPerspectiveClient } from "~/components/v2/extractions/perspective-bootstrap"; -import { type WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; -import { toPerspectiveData, bandParity } from "~/v2/domain/entities/projection/grid-adapter"; +import { type WorkspaceProjection, type ProjectionGridCell } from "~/v2/domain/entities/projection/WorkspaceProjection"; +import { toPerspectiveData, cellAt, bandParity } from "~/v2/domain/entities/projection/grid-adapter"; /** * Vue wrapper around `` (§3.1/§3.3 extraction grid). The `.client.vue` @@ -17,19 +17,18 @@ import { toPerspectiveData, bandParity } from "~/v2/domain/entities/projection/g * Loads the flat projection into a static Datagrid (no sort/group/filter — natural * insertion order, toolbar hidden), reloading the underlying Perspective table whenever * `props.projection` changes (the host page swaps workspaces without remounting this - * component). - * - * The grid is read-only: its one DOM-level addition is reference-group row banding - * (`bandParity`), which Perspective has no concept of. It is re-applied on every - * `addStyleListener` draw because virtualized ``s are recycled and never retain classes - * across redraws. Cells are deliberately not clickable — navigating from a cell to v1 - * annotation-mode is future work, and shipping the plumbing behind an always-off guard only - * created dead code. + * component). Layers two DOM-only affordances on top of the plugin's `regular-table` since + * Perspective itself has no concept of either: + * - reference-group row banding (`bandParity`), re-applied on every `addStyleListener` draw + * because virtualized ``s are recycled and never retain classes across redraws. + * - a single `click` listener on the viewer host, resolved through `event.composedPath()` + * so it still finds the cell whether the datagrid plugin rendered into shadow or light DOM. */ interface CellMeta { type: string; y?: number; + column_header?: Array; } interface RegularTableLike extends HTMLElement { @@ -48,6 +47,7 @@ interface PerspectiveClientLike { const props = defineProps<{ projection: WorkspaceProjection }>(); const emit = defineEmits<{ + "cell-click": [payload: { cell: ProjectionGridCell; reference: string; schemaId: string; columnName: string }]; // Fired when building/loading a Perspective table for the current projection fails (e.g. // `client.table()` rejects). The host page maps this onto its existing `loadError` state // so a rejection here can never again leave the user staring at a blank, unexplained @@ -110,27 +110,35 @@ async function deleteSupersededTable(candidate: PerspectiveTableLike | null): Pr } } -function rowIndexAt(rt: RegularTableLike, td: HTMLElement): number | null { +function cellMetaAt(rt: RegularTableLike, td: HTMLElement): { rowIndex: number; columnName: string } | null { const meta = rt.getMeta(td); // regular-table uses a single DOM interface (HTMLTableCellElement) for both and - // , and CellMetadataRowHeader carries the same required `y` shape as a body cell's - // metadata. Without this check a row-header would be misread as a body cell and - // banded against the wrong reference group. - if (meta?.type !== "body" || meta.y === undefined) { + // , and CellMetadataRowHeader carries the same required `y` + `column_header` shape + // as a body cell's metadata. Without this check a row-header can pass the rest of + // the guard below and be misread as a body cell. + if (meta?.type !== "body") { + return null; + } + const rowIndex = meta.y; + const header = meta.column_header; + const columnName = header && header.length > 0 ? header[header.length - 1] : undefined; + if (rowIndex === undefined || typeof columnName !== "string") { return null; } - return meta.y; + return { rowIndex, columnName }; } /** - * The one style-only affordance applied to grid ``s: reference-group row banding. - * Declared once here and reused by `ensureShadowStyles` below; the ` diff --git a/extralit-frontend/pages/extractions/index.vue b/extralit-frontend/pages/extractions/index.vue index ee39b9da3..6a6d0bfbd 100644 --- a/extralit-frontend/pages/extractions/index.vue +++ b/extralit-frontend/pages/extractions/index.vue @@ -12,7 +12,7 @@ - + diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts index 47d8d73af..15ef792aa 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -105,6 +105,12 @@ describe("useExtractionsViewModel", () => { expect(vm.loadFailed.value).toBe(true); }); + it("builds the annotation URL on cell click but does not navigate (guard off)", () => { + const vm = useExtractionsViewModel("w-1"); + const url = vm.onCellClick({ schemaId: "s-1", reference: "10.1/a b" }); + expect(url).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); + }); + it("flags load failure when the grid reports a load-error after a successful projection load", async () => { // Reproduces ExtractionsGrid's `load-error` emit (e.g. `client.table()` rejecting on a // non-scalar cell value): the projection itself loaded fine, but the page's state diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts index 3d80d1cfc..be930727d 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -2,6 +2,7 @@ import { computed, ref, shallowRef, watch } from "vue"; import { useResolve } from "ts-injecty"; import { GetWorkspaceProjectionUseCase } from "~/v2/domain/usecases/get-workspace-projection-use-case"; import { type WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; +import { buildAnnotationUrl, ANNOTATION_CELL_LINKS_ENABLED } from "~/v2/domain/entities/projection/grid-adapter"; // Documented v1 exception: workspace selection survives Phase 6 (see plan Global Constraints). import { useWorkspaces } from "~/v1/infrastructure/storage/WorkspaceStorage"; @@ -85,6 +86,15 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => watch(workspaceId, load); + const onCellClick = ({ schemaId, reference }: { schemaId: string; reference: string }): string => { + const url = buildAnnotationUrl(schemaId, reference); + if (ANNOTATION_CELL_LINKS_ENABLED) { + // Guarded off: annotation-mode cannot resolve v2 schema ids yet (see grid-adapter.ts). + window.location.href = url; + } + return url; + }; + // Wired to `ExtractionsGrid`'s `load-error` emit (see its doc comment): a rejection while // building/loading the Perspective table for the current projection is surfaced through // this same `loadFailed` flag the page already renders `extractions.loadError` for, so the @@ -93,5 +103,5 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => loadFailed.value = true; }; - return { projection, isLoading, loadFailed, hasLoaded, workspaceId, load, onGridLoadError }; + return { projection, isLoading, loadFailed, hasLoaded, workspaceId, load, onCellClick, onGridLoadError }; }; diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts index ee89e10de..cd9ee5dc0 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { bandParity, REFERENCE_COLUMN, toPerspectiveData } from "./grid-adapter"; +import { + ANNOTATION_CELL_LINKS_ENABLED, + bandParity, + buildAnnotationUrl, + cellAt, + REFERENCE_COLUMN, + toPerspectiveData, +} from "./grid-adapter"; import { WorkspaceProjection, type ProjectionGridCell } from "./WorkspaceProjection"; const cell = (value: unknown): ProjectionGridCell => ({ @@ -157,6 +164,17 @@ describe("toPerspectiveData", () => { }); }); +describe("cellAt", () => { + it("returns the enriched cell for the loaded row order", () => { + expect(cellAt(PROJECTION, 0, "Design.type")?.recordId).toBe("r-1"); + }); + + it("returns null for empty cells and out-of-range rows", () => { + expect(cellAt(PROJECTION, 2, "Design.type")).toBeNull(); + expect(cellAt(PROJECTION, 99, "Design.type")).toBeNull(); + }); +}); + describe("bandParity", () => { it("flips parity when the reference changes, not per row", () => { expect(bandParity(PROJECTION)).toEqual([0, 0, 1]); @@ -180,3 +198,21 @@ describe("bandParity", () => { expect(bandParity(empty)).toEqual([]); }); }); + +describe("annotation URL contract (spec §3.3)", () => { + it("stays guarded off in this build", () => { + expect(ANNOTATION_CELL_LINKS_ENABLED).toBe(false); + }); + + it("puts the schema id in the dataset slot and encodes the reference", () => { + expect(buildAnnotationUrl("s-1", "10.1/a b")).toBe("/dataset/s-1/annotation-mode?_search=10.1%2Fa%20b"); + }); + + it("encodes reserved query characters (&, #) in the reference", () => { + expect(buildAnnotationUrl("s-1", "a&b#c")).toBe("/dataset/s-1/annotation-mode?_search=a%26b%23c"); + }); + + it("encodes reserved characters in the schema id path segment too", () => { + expect(buildAnnotationUrl("s/1", "10.1/a")).toBe("/dataset/s%2F1/annotation-mode?_search=10.1%2Fa"); + }); +}); diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts index 9de494beb..bf89039b8 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts @@ -1,4 +1,4 @@ -import { type WorkspaceProjection } from "./WorkspaceProjection"; +import { type WorkspaceProjection, type ProjectionGridCell } from "./WorkspaceProjection"; /** * Flat-row column name carrying the reference (DOI/identifier) for each row. @@ -72,6 +72,25 @@ function toScalarCell(value: unknown): unknown { } } +/** + * Looks up the enriched cell (value + provenance) for a grid position. + * + * Relies on the static-grid invariant: the projection is loaded once, unsorted and + * unfiltered, so a datagrid row index maps 1:1 onto `projection.rows` by array position. + * Returns null for an absent cell or an out-of-range row index. + */ +export function cellAt( + projection: WorkspaceProjection, + rowIndex: number, + columnName: string +): ProjectionGridCell | null { + const row = projection.rows[rowIndex]; + if (!row) { + return null; + } + return row.cells[columnName] ?? null; +} + /** * Returns a 0/1 parity value per row that flips whenever `reference` changes from the * previous row (not per row). Perspective has no notion of merged/grouped cells, so this @@ -88,3 +107,18 @@ export function bandParity(projection: WorkspaceProjection): number[] { return parity; }); } + +/** + * Guards the §3.3 click-to-annotate affordance off until annotation-mode resolves v2 schema + * ids (see ledger §5). Flip to true once that lands. + */ +export const ANNOTATION_CELL_LINKS_ENABLED = false; + +/** + * Builds the annotation-mode deep link for a cell's reference, percent-encoding both the + * schema id (path segment) and the reference (query value) so slashes, `&`, `#`, and other + * reserved characters can't reshape the URL. + */ +export function buildAnnotationUrl(schemaId: string, reference: string): string { + return `/dataset/${encodeURIComponent(schemaId)}/annotation-mode?_search=${encodeURIComponent(reference)}`; +} From b8651b0c894bf41a8efbf48e14b810cfe1ab71af Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 25 Jul 2026 11:59:16 -0700 Subject: [PATCH 29/43] feat(ui): add navigation tab for extractions Introduced a new navigation tab for extractions in the index.vue file. Updated the tab change handler to utilize a centralized route mapping for better maintainability. This change enhances the user interface by providing direct access to the extractions section. --- .../e2e/v2/extractions-nav.spec.ts | 29 +++++++++++++++++++ extralit-frontend/pages/index.vue | 11 +++++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 extralit-frontend/e2e/v2/extractions-nav.spec.ts diff --git a/extralit-frontend/e2e/v2/extractions-nav.spec.ts b/extralit-frontend/e2e/v2/extractions-nav.spec.ts new file mode 100644 index 000000000..4658157c9 --- /dev/null +++ b/extralit-frontend/e2e/v2/extractions-nav.spec.ts @@ -0,0 +1,29 @@ +import { expect, signIn, test } from "./fixtures"; + +/** + * The `/extractions` page is reachable from the home tab bar. + * + * `schemas` and `extractions` are *navigation* tabs (see `NAVIGATION_TAB_ROUTES` in + * `pages/index.vue`): unlike `datasets`/`documents`, which swap the panel below the tab bar, + * selecting one routes to a page of its own. Asserted against the real backend rather than a + * mocked mount because the failure this guards against — a tab that renders but routes + * nowhere — is a router-level behaviour a shallowMount would stub away. + * + * Needs no seed: the tab bar and the route are workspace-independent. + */ +test("home exposes an Extractions tab, after Datasets/Documents/Schemas, that opens /extractions", async ({ page }) => { + await signIn(page); + await page.goto("/"); + + const tabs = page.locator(".tabs .tab__button"); + await expect(tabs).toHaveCount(4); + // Order matters: the tab was specified to sit after the three existing ones. + await expect(tabs).toHaveText([/Datasets/i, /Documents/i, /Schemas/i, /Extractions/i]); + + await tabs.filter({ hasText: /Extractions/i }).click(); + await page.waitForURL((url) => url.pathname === "/extractions"); + + // Landed on the real page, not a blank route: its title renders regardless of whether the + // selected workspace has any extractions. + await expect(page.getByRole("heading", { name: "Extractions" })).toBeVisible(); +}); diff --git a/extralit-frontend/pages/index.vue b/extralit-frontend/pages/index.vue index fd4de5381..89827bf82 100644 --- a/extralit-frontend/pages/index.vue +++ b/extralit-frontend/pages/index.vue @@ -123,6 +123,11 @@ import Home from "@/layouts/Home.vue"; import { useHomeViewModel } from "./useHomeViewModel"; import { Workspace } from "~/v1/domain/entities/workspace/Workspace"; +const NAVIGATION_TAB_ROUTES: Record = { + schemas: "/schemas", + extractions: "/extractions", +}; + export default { components: { Home, @@ -139,6 +144,7 @@ export default { { id: "datasets", name: this.$t("home.datasets") }, { id: "documents", name: this.$t("home.documents") }, { id: "schemas", name: this.$t("schemas.title") }, + { id: "extractions", name: this.$t("extractions.title") }, ], // Import details modal state isImportDetailsModalVisible: false, @@ -173,8 +179,9 @@ export default { }, onTabChange(tabId) { - if (tabId === "schemas") { - this.$router.push("/schemas"); + const route = NAVIGATION_TAB_ROUTES[tabId]; + if (route) { + this.$router.push(route); return; } const selectedTab = this.tabs.find((tab) => tab.id === tabId); From 1599a8a16b4e61745dad7d0a9284da27a3fce2d0 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 25 Jul 2026 13:03:13 -0700 Subject: [PATCH 30/43] chore: update CHANGELOG and remove unused files Updated the CHANGELOG to correct links and removed the license header and Dockerfile for the frontend, as they are no longer needed. Adjusted workspace references in the dataset API mock and test files to reflect the new naming convention. This cleanup enhances project maintainability. --- .../header-bar/headerFeedbackTask.spec.js | 4 +-- extralit-frontend/dev.frontend.Dockerfile | 32 ------------------- .../e2e/common/dataset-api-mock.ts | 18 +++++------ extralit-frontend/tsconfig.json | 10 ++---- extralit-server/CHANGELOG.md | 2 +- extralit-server/LICENSE | 14 -------- extralit/CHANGELOG.md | 2 +- extralit/license_header.txt | 13 -------- 8 files changed, 15 insertions(+), 80 deletions(-) delete mode 100644 extralit-frontend/dev.frontend.Dockerfile delete mode 100644 extralit/license_header.txt diff --git a/extralit-frontend/components/features/annotation/header/header-bar/headerFeedbackTask.spec.js b/extralit-frontend/components/features/annotation/header/header-bar/headerFeedbackTask.spec.js index 2f43f9c00..4341f2da9 100644 --- a/extralit-frontend/components/features/annotation/header/header-bar/headerFeedbackTask.spec.js +++ b/extralit-frontend/components/features/annotation/header/header-bar/headerFeedbackTask.spec.js @@ -20,12 +20,12 @@ const options = { props: { breadcrumbs: [ { link: { name: "datasets" }, name: "Home" }, - { link: { path: "/?workspace=recognai" }, name: "recognai" }, + { link: { path: "/?workspace=extralit" }, name: "extralit" }, { link: { name: null, params: { - workspace: "recognai", + workspace: "extralit", dataset: "imdb-single-label-all-records", }, }, diff --git a/extralit-frontend/dev.frontend.Dockerfile b/extralit-frontend/dev.frontend.Dockerfile deleted file mode 100644 index c8abdc330..000000000 --- a/extralit-frontend/dev.frontend.Dockerfile +++ /dev/null @@ -1,32 +0,0 @@ -ARG extralit_server_TAG=develop - -FROM extralitdev/extralit-hf-space:${extralit_server_TAG} - -USER root - -RUN apt-get update && \ - apt-get install -y nodejs npm - -USER extralit - -WORKDIR /home/extralit/frontend - -COPY --chown=extralit:extralit dist ./dist -COPY --chown=extralit:extralit .nuxt ./.nuxt -COPY --chown=extralit:extralit package.json ./package.json -COPY --chown=extralit:extralit package-lock.json ./package-lock.json -COPY --chown=extralit:extralit nuxt.config.ts ./nuxt.config.ts - -# NOTE: Right now this Docker image is using dev.extralit.io as server. -# If we want to use a built-in server in the future to check all functionality we can modify the following Procfile -# content adding ElasticSearch and extralit-server processes. -RUN npm install && \ - echo 'frontend: cd /home/extralit/frontend && HOST=0.0.0.0 PORT=3000 npm run start\n' > /home/extralit/Procfile.frontend - -WORKDIR /home/extralit/ - -EXPOSE 3000 -EXPOSE 6900 -EXPOSE 9200 - -CMD ["honcho", "start", "--procfile", "Procfile.frontend"] diff --git a/extralit-frontend/e2e/common/dataset-api-mock.ts b/extralit-frontend/e2e/common/dataset-api-mock.ts index 457d1fe4c..1779f38c6 100644 --- a/extralit-frontend/e2e/common/dataset-api-mock.ts +++ b/extralit-frontend/e2e/common/dataset-api-mock.ts @@ -33,11 +33,11 @@ const oldDatasets = [ metadata: {}, name: "text2text-10-predictions", task: "Text2Text", - workspace: "recognai", - id: "recognai.text2text-10-predictions", - owner: "recognai", + workspace: "extralit", + id: "extralit.text2text-10-predictions", + owner: "extralit", created_at: fakeDateMonthAgo(6), - created_by: "recognai", + created_by: "extralit", last_updated: fakeDateMonthAgo(3), }, { @@ -45,11 +45,11 @@ const oldDatasets = [ metadata: {}, name: "settings_textclass_with_labels", task: "TextClassification", - workspace: "recognai", - id: "recognai.settings_textclass_with_labels", - owner: "recognai", + workspace: "extralit", + id: "extralit.settings_textclass_with_labels", + owner: "extralit", created_at: fakeDateMonthAgo(6), - created_by: "recognai", + created_by: "extralit", last_updated: fakeDateMonthAgo(6), }, ]; @@ -99,7 +99,7 @@ export const workspacesMocked = [ }, { id: "9c14ca14-65bb-4c27-ba1f-cf7e4a6398e8", - name: "recognai", + name: "extralit", inserted_at: fakeDateMonthAgo(1), updated_at: fakeDateMonthAgo(1), }, diff --git a/extralit-frontend/tsconfig.json b/extralit-frontend/tsconfig.json index bdd21c41e..f58adff44 100644 --- a/extralit-frontend/tsconfig.json +++ b/extralit-frontend/tsconfig.json @@ -1,16 +1,10 @@ { - // Nuxt 4 generates .nuxt/tsconfig.json with strict:true. The pre-Vue3 project - // explicitly ran with strict:false ("// Temporal"); preserve that posture so the - // migration doesn't newly impose strict-null / no-implicit-any on a codebase that - // never satisfied them. Tightening to strict is a separate hardening effort. "extends": "./.nuxt/tsconfig.json", "compilerOptions": { "strict": false, - // Nuxt 4 newly enables these; the pre-Vue3 tsconfig had neither. Keep them off to - // match original posture. (Runtime type-only erasure is handled by inline `import type` - // modifiers for Vite/esbuild isolatedModules, independent of verbatimModuleSyntax.) "verbatimModuleSyntax": false, "noImplicitOverride": false, "types": ["node", "vitest/globals"] - } + }, + "exclude": ["node_modules", "dist", ".data", "demo"] } diff --git a/extralit-server/CHANGELOG.md b/extralit-server/CHANGELOG.md index b4f17b9cb..069a87c3d 100644 --- a/extralit-server/CHANGELOG.md +++ b/extralit-server/CHANGELOG.md @@ -1265,7 +1265,7 @@ These are the section headers that we use: ### Fixes - Copying datasets between workspaces with proper owner/workspace info. Closes [#2562](https://github.com/argilla-io/argilla/issues/2562) -- Copy dataset with empty workspace to the default user workspace [905d4de](https://github.com/recognai/argilla/commit/905d4deaa769bfc9bbc022cd2dc75c7435cfe865) +- Copy dataset with empty workspace to the default user workspace [905d4de](https://github.com/argilla-io/argilla/commit/905d4deaa769bfc9bbc022cd2dc75c7435cfe865) - Using elasticsearch config to request backend version. Closes [#2311](https://github.com/argilla-io/argilla/issues/2311) - Remove sorting by score in labels. Closes [#2622](https://github.com/argilla-io/argilla/issues/2622) diff --git a/extralit-server/LICENSE b/extralit-server/LICENSE index a4c5dcfe7..3c4dfe081 100644 --- a/extralit-server/LICENSE +++ b/extralit-server/LICENSE @@ -185,17 +185,3 @@ file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. - - Copyright [2021] [Recognai S.L.] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/extralit/CHANGELOG.md b/extralit/CHANGELOG.md index 6e84834a4..770e60a05 100644 --- a/extralit/CHANGELOG.md +++ b/extralit/CHANGELOG.md @@ -1228,7 +1228,7 @@ These are the section headers that we use: ### Fixes - Copying datasets between workspaces with proper owner/workspace info. Closes [#2562](https://github.com/argilla-io/argilla/issues/2562) -- Copy dataset with empty workspace to the default user workspace [905d4de](https://github.com/recognai/argilla/commit/905d4deaa769bfc9bbc022cd2dc75c7435cfe865) +- Copy dataset with empty workspace to the default user workspace [905d4de](https://github.com/argilla-io/argilla/commit/905d4deaa769bfc9bbc022cd2dc75c7435cfe865) - Using elasticsearch config to request backend version. Closes [#2311](https://github.com/argilla-io/argilla/issues/2311) - Remove sorting by score in labels. Closes [#2622](https://github.com/argilla-io/argilla/issues/2622) diff --git a/extralit/license_header.txt b/extralit/license_header.txt deleted file mode 100644 index bc08648bf..000000000 --- a/extralit/license_header.txt +++ /dev/null @@ -1,13 +0,0 @@ - Copyright 2021-present, the Recognai S.L. team. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. From c269ab44ed8e62bb4f725552df11e0182a59ffda Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 25 Jul 2026 13:15:27 -0700 Subject: [PATCH 31/43] fix(v2-ui): close roborev findings on the extractions grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Medium findings from roborev jobs 249, 250, 251, 255, 259 (256's medium was already moot after the dad64f95 revert). - perspective-bootstrap: `fetch` only rejects on a network-level failure, so an HTTP error status resolved normally and `init_server` stashed the error `Response` — the boot memoized as *resolved* and the failure re-emerged as an unhandled rejection from `worker()`. Both fetches now assert `response.ok`. - useExtractionsViewModel: the in-flight dedupe matched on workspace id alone, so a deselect-then-reselect of the same workspace adopted the superseded promise, issued no request, and left the page on the empty state for a workspace with data. The guard now requires token currency. - demo harness: page errors could not fail the gate (a green "N/N passed" video on an uncaught app exception), and `rm -rf "$OUT"` was unguarded on a documented env knob. Page errors are now fatal (DEMO_ALLOW_PAGE_ERRORS=1 to override) and deletion requires a marker file the script itself creates. - grid-adapter: record why the cell-click chain is retained ahead of its consumer (ENG-32) so the next cleanup pass doesn't remove it a second time. - cellMetaAt: correct a comment that misdescribed row-header metadata as carrying `column_header`. Adds specs for the three gaps these expose: a resolved-but-!ok WASM fetch, the reselect race, and — via a fake `regular_table` in a real shadow root — the banding/linkable classes landing on the first draw. Each was mutation-verified to fail without its fix. 915 unit tests pass, lint clean, `nuxi typecheck` exit 0. --- .../ExtractionsGrid.client.test.ts | 131 ++++++++++++++++++ .../v2/extractions/ExtractionsGrid.client.vue | 12 +- .../extractions/perspective-bootstrap.test.ts | 21 +++ .../v2/extractions/perspective-bootstrap.ts | 18 ++- extralit-frontend/demo/record-demo.mjs | 11 ++ extralit-frontend/demo/run-demo.sh | 18 ++- .../useExtractionsViewModel.test.ts | 38 +++++ .../extractions/useExtractionsViewModel.ts | 13 +- .../entities/projection/grid-adapter.ts | 9 ++ 9 files changed, 262 insertions(+), 9 deletions(-) diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts index 2dc75c7ef..21c3f3816 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.test.ts @@ -194,4 +194,135 @@ describe("ExtractionsGrid", () => { expect(firstDelete).toHaveBeenCalledTimes(1); expect(secondDelete).not.toHaveBeenCalled(); }); + + describe("cell styling", () => { + // The banding/pointer affordances are the two things the shadow-DOM style fix exists for, + // and neither was reachable by any spec: under happy-dom `getPlugin` resolves to + // `undefined`, so `regularTable` stays null and `ensureShadowStyles`/`applyCellStyles` + // never run. Stubbing `getPlugin` with a fake `regular_table` living in a REAL + // `attachShadow` root makes both assertable — including the property that broke twice in + // review: that the classes land on the FIRST draw, with no redraw and without the + // style listener ever firing. + const BANDING_PROJECTION = new WorkspaceProjection( + [ + { + name: "Design.type", + schemaId: "s-1", + schemaName: "Design", + questionName: "type", + subColumn: null, + dtype: "text", + }, + ], + [ + { + reference: "10.1/a", + rowIndex: 0, + cells: { + "Design.type": { value: "RCT", source: "response", recordId: "r-1", agent: null, score: null }, + }, + }, + // Second reference: `bandParity` flips on reference change, so this row bands while + // row 0 does not. Its cell is absent, so it is also the not-linkable case. + { reference: "10.2/b", rowIndex: 0, cells: {} }, + ], + 2 + ); + + /** + * A fake `regular-table`: a real element inside a real open shadow root, carrying one + * `` per (row, column) plus a `` standing in for a row header, with `getMeta` + * returning the metadata shapes regular-table emits for each. + */ + const fakeRegularTable = () => { + const host = document.createElement("div"); + document.body.appendChild(host); + const shadow = host.attachShadow({ mode: "open" }); + const rt = document.createElement("div"); + shadow.appendChild(rt); + + const meta = new Map>(); + const makeCell = (tag: "td" | "th", cellMeta: Record) => { + const cell = document.createElement(tag); + rt.appendChild(cell); + meta.set(cell, cellMeta); + return cell; + }; + + const row0 = makeCell("td", { type: "body", y: 0, column_header: ["Design.type"] }); + const row1 = makeCell("td", { type: "body", y: 1, column_header: ["Design.type"] }); + // A row header: same `HTMLTableCellElement` interface, but `type: "row_header"` — it + // must never be banded against a body row's parity. + const rowHeader = makeCell("th", { type: "row_header", y: 1, row_header: ["10.2/b"] }); + + const styleListener = vi.fn(); + Object.assign(rt, { + getMeta: (cell: Element) => meta.get(cell), + addStyleListener: (callback: unknown) => { + styleListener(callback); + return () => undefined; + }, + }); + + return { host, shadow, rt, row0, row1, rowHeader, styleListener }; + }; + + const mountWithPlugin = async (table: ReturnType) => { + const wrapper = mountGrid(BANDING_PROJECTION); + const viewerNode = wrapper.element as unknown as Record; + viewerNode.load = vi.fn(async () => undefined); + viewerNode.restore = vi.fn(async () => undefined); + viewerNode.eject = vi.fn(async () => undefined); + viewerNode.getPlugin = vi.fn(async () => ({ regular_table: table.rt })); + viewerNode.addEventListener = vi.fn(); + viewerNode.removeEventListener = vi.fn(); + await flushPromises(); + return wrapper; + }; + + it("bands the second reference's cells and marks populated cells linkable on the first draw, without waiting for a redraw", async () => { + const table = fakeRegularTable(); + await mountWithPlugin(table); + + // Row 0 is the first reference (parity 0); row 1 is the second (parity 1). + expect(table.row0.classList.contains("extractions-grid__band")).toBe(false); + expect(table.row1.classList.contains("extractions-grid__band")).toBe(true); + + // `Design.type` is populated on row 0 only, so only that cell advertises clickability. + expect(table.row0.classList.contains("extractions-grid__linkable")).toBe(true); + expect(table.row1.classList.contains("extractions-grid__linkable")).toBe(false); + + // The crux: this all happened on the explicit first-paint `applyCellStyles` call. + // `addStyleListener` only registers a callback — it neither invokes it nor forces a + // redraw — so without that explicit call nothing would be styled until a scroll or + // resize, i.e. never for a user who just reads the grid. + expect(table.styleListener).toHaveBeenCalledTimes(1); + const registeredCallback = table.styleListener.mock.calls[0][0]; + expect(registeredCallback).toBeTypeOf("function"); + }); + + // NOTE: there is deliberately no "row headers are never banded" spec here. Three + // independent mechanisms already guarantee it — `applyCellStyles` scopes to + // `querySelectorAll("td")`, `cellMetaAt` rejects `type !== "body"`, and a real row + // header's metadata carries `row_header`/`row_header_x` rather than the `column_header` + // the name resolution requires. Mutation-testing confirmed no such spec can fail even + // with the first two removed, so it would assert nothing. See `cellMetaAt`'s comment. + + it("injects the cell stylesheet into the shadow root exactly once across repeated loads", async () => { + const table = fakeRegularTable(); + const wrapper = await mountWithPlugin(table); + + const styles = () => table.shadow.querySelectorAll("style#extractions-grid-cell-style"); + expect(styles()).toHaveLength(1); + // Document-level CSS (including this component's Vue `:deep()` rules) cannot cross a + // shadow boundary, which is why the rules are injected here at all. + expect(styles()[0].textContent).toContain("extractions-grid__band"); + + await wrapper.setProps({ projection: PROJECTION }); + await flushPromises(); + + // Id-guarded: a second load must not append a duplicate. + expect(styles()).toHaveLength(1); + }); + }); }); diff --git a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue index 14abce775..d5f7a2c57 100644 --- a/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue +++ b/extralit-frontend/components/v2/extractions/ExtractionsGrid.client.vue @@ -112,10 +112,14 @@ async function deleteSupersededTable(candidate: PerspectiveTableLike | null): Pr function cellMetaAt(rt: RegularTableLike, td: HTMLElement): { rowIndex: number; columnName: string } | null { const meta = rt.getMeta(td); - // regular-table uses a single DOM interface (HTMLTableCellElement) for both and - // , and CellMetadataRowHeader carries the same required `y` + `column_header` shape - // as a body cell's metadata. Without this check a row-header can pass the rest of - // the guard below and be misread as a body cell. + // Defence-in-depth, not the load-bearing guard: regular-table uses a single DOM interface + // (HTMLTableCellElement) for both and and row-header metadata also carries `y`, + // so only `type` distinguishes them here. In practice a row header cannot reach this + // function's callers anyway — `applyCellStyles` scopes to `querySelectorAll("td")`, + // `handleClick` filters `composedPath()` to `tagName === "TD"`, and a row header's + // metadata carries `row_header`/`row_header_x` rather than the `column_header` the name + // resolution below requires (regular-table@^0.7 `types.d.ts`). Kept for a future caller + // that isn't so scoped; do not read it as the reason ``s stay unstyled today. if (meta?.type !== "body") { return null; } diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts index 5cc924b9a..fc3f3f14c 100644 --- a/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.test.ts @@ -79,6 +79,27 @@ describe("initPerspective", () => { expect(initServerSpy).toHaveBeenCalledTimes(1); // only the successful attempt reaches init_server }); + it("treats an HTTP error status on the server WASM as a boot failure, even though fetch itself resolves", async () => { + // The failure mode a *throwing* `fetch` mock cannot reach: `fetch` only rejects on a + // network-level failure, so a 404 (the redeploy case the module's own comment names) + // RESOLVES with `ok: false`. Unguarded, `init_server` stashes that 404 `Response`, the + // boot memoizes as *resolved*, and the real failure only surfaces later as an unhandled + // rejection when `worker()` awaits the stashed promise. + fetchMock.mockImplementationOnce(async () => ({ ok: false, status: 404 })); + initServerSpy.mockImplementation(() => undefined); + initClientSpy.mockImplementation(() => Promise.resolve()); + + const { initPerspective } = await import("./perspective-bootstrap"); + + await expect(initPerspective()).rejects.toThrow(/server\.wasm → 404/); + // The rejected attempt must never have handed the error response on to the engine... + expect(initServerSpy).not.toHaveBeenCalled(); + // ...and the memo must have reset, so a redeploy that fixes the asset recovers without + // a hard page reload. + await expect(initPerspective()).resolves.toBeDefined(); + expect(initServerSpy).toHaveBeenCalledTimes(1); + }); + it("resets the memoized boot promise when init_client rejects, so the next call retries instead of replaying the same failure", async () => { initServerSpy.mockImplementation(() => undefined); initClientSpy.mockImplementationOnce(() => Promise.reject(new Error("client init failed"))); diff --git a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts index 2b47e42b6..950a4af12 100644 --- a/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts +++ b/extralit-frontend/components/v2/extractions/perspective-bootstrap.ts @@ -26,10 +26,26 @@ import CLIENT_WASM from "@perspective-dev/viewer/dist/wasm/perspective-viewer.wa // `worker()` awaits the stashed promise. let ready: Promise | null = null; +// `fetch` only rejects on a network-level failure — an HTTP error status *resolves* with +// `ok: false`. Without this check the headline case named above (a 404 right after a +// redeploy) never reaches `attempt`: both fetches resolve, `init_server` stashes the 404 +// `Response`, and the failure re-emerges later as an unhandled rejection when `worker()` +// awaits the stashed promise, with `ready` already memoized as *resolved*. Throwing here is +// what puts an HTTP failure on the same path as a network failure. +const assertOk = (response: Response, url: string): Response => { + if (!response.ok) { + throw new Error(`Perspective WASM fetch failed: ${url} → ${response.status}`); + } + return response; +}; + export const initPerspective = (): Promise => { if (!ready) { const attempt: Promise = (async () => { - const [serverWasm, clientWasm] = await Promise.all([fetch(SERVER_WASM), fetch(CLIENT_WASM)]); + const [serverWasm, clientWasm] = await Promise.all([ + fetch(SERVER_WASM).then((response) => assertOk(response, SERVER_WASM)), + fetch(CLIENT_WASM).then((response) => assertOk(response, CLIENT_WASM)), + ]); perspective.init_server(serverWasm); await perspective_viewer.init_client(clientWasm); return perspective; diff --git a/extralit-frontend/demo/record-demo.mjs b/extralit-frontend/demo/record-demo.mjs index 97aaf2fdd..23398f1b7 100644 --- a/extralit-frontend/demo/record-demo.mjs +++ b/extralit-frontend/demo/record-demo.mjs @@ -341,6 +341,17 @@ const main = async () => { if (pageErrors.length) console.log(`page errors:\n${pageErrors.join("\n")}`); if (failures.length) { console.log(`FAILURES:\n${failures.join("\n")}`); + } + // An uncaught exception in the app is the most severe class of runtime breakage, and every + // scene assertion can still pass around it — so gating the exit code on `failures` alone + // would let a `pageerror` produce a green "N/N checks passed" video and a zero exit, + // exactly the "broken UI dressed up as a finished video" this harness exists to prevent. + // `DEMO_ALLOW_PAGE_ERRORS=1` is the explicit opt-out for known-benign noise. + const allowPageErrors = process.env.DEMO_ALLOW_PAGE_ERRORS === "1"; + if (pageErrors.length && !allowPageErrors) { + console.log(`page errors are fatal (set DEMO_ALLOW_PAGE_ERRORS=1 to override)`); + } + if (failures.length || (pageErrors.length && !allowPageErrors)) { process.exitCode = 1; } }; diff --git a/extralit-frontend/demo/run-demo.sh b/extralit-frontend/demo/run-demo.sh index 6f19b1a12..aff3e2c61 100755 --- a/extralit-frontend/demo/run-demo.sh +++ b/extralit-frontend/demo/run-demo.sh @@ -15,8 +15,24 @@ FRONTEND="$(dirname "$HERE")" OUT="${DEMO_OUT:-${TMPDIR:-/tmp}/extralit-extractions-demo}" echo "==> output: $OUT" -rm -rf "$OUT" + +# `DEMO_OUT` is a documented knob, and an unguarded `rm -rf "$OUT"` on it will happily wipe +# whatever the caller points at — `DEMO_OUT=$HOME`, `DEMO_OUT=$PWD`, or a path with a typo. +# Only ever recursively delete a directory this script created, identified by a marker file. +MARKER=".extralit-demo-out" +case "$OUT" in + "" | "/" | "$HOME" | "$HOME/") echo "refusing to use '$OUT' as the demo output directory" >&2; exit 1 ;; +esac +if [ -e "$OUT" ]; then + if [ ! -f "$OUT/$MARKER" ]; then + echo "refusing to delete '$OUT': not a demo output directory (no $MARKER marker)." >&2 + echo "Remove it yourself, or point DEMO_OUT at a fresh path." >&2 + exit 1 + fi + rm -rf "$OUT" +fi mkdir -p "$OUT" +touch "$OUT/$MARKER" echo "==> seeding demo workspaces" uv run --project "$FRONTEND/../extralit-server" python "$HERE/seed_demo_workspace.py" \ diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts index 15ef792aa..60bad09b1 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.test.ts @@ -98,6 +98,44 @@ describe("useExtractionsViewModel", () => { expect(vm.projection.value).toBeNull(); }); + it("reselecting the same workspace mid-flight issues a fresh request instead of adopting the superseded one", async () => { + const firstCall = deferred(); + const secondCall = deferred(); + executeMock.mockReturnValueOnce(firstCall.promise).mockReturnValueOnce(secondCall.promise); + + const { saveSelectedWorkspace } = useWorkspaces(); + const workspace = new Workspace("w-1", "Workspace 1"); + saveSelectedWorkspace(workspace); + + const vm = useExtractionsViewModel(); + const load1 = vm.load(); + + // Deselect, then reselect the *same* workspace while the original load is still running. + // Both are ordinary workspace-selector firings. + saveSelectedWorkspace(null); + await vm.load(); + saveSelectedWorkspace(workspace); + const load3 = vm.load(); + + // Without the currency check in the dedupe guard, `inFlight.workspaceId === "w-1"` still + // matches, so this reselect hands back the superseded promise and issues no request at + // all. That promise then fails its own token check, assigns no projection and never sets + // `isLoading` — leaving the page on "no extracted references" for a workspace with data. + expect(executeMock).toHaveBeenCalledTimes(2); + + const staleProjection = new WorkspaceProjection([], [], 1); + firstCall.resolve(staleProjection); + await load1; + expect(vm.projection.value).toBeNull(); + + const freshProjection = new WorkspaceProjection([], [], 7); + secondCall.resolve(freshProjection); + await load3; + + expect(vm.projection.value).toBe(freshProjection); + expect(vm.isLoading.value).toBe(false); + }); + it("flags load failure", async () => { executeMock.mockRejectedValue(new Error("boom")); const vm = useExtractionsViewModel("w-1"); diff --git a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts index be930727d..3aa2b8f24 100644 --- a/extralit-frontend/pages/extractions/useExtractionsViewModel.ts +++ b/extralit-frontend/pages/extractions/useExtractionsViewModel.ts @@ -38,8 +38,9 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => if (!id) { // Bump the token so any in-flight load for a previous (now-deselected) workspace is // recognized as superseded when it settles, instead of committing a projection for a - // workspace the user is no longer in. `inFlight` is deliberately left alone: it is - // keyed by workspace id/token and the in-flight call's own `finally` will clear it. + // workspace the user is no longer in. `inFlight` is deliberately left alone: the + // in-flight call's own `finally` clears it, and the currency check in the dedupe guard + // below stops a reselect of this same workspace from adopting the now-stale entry. ++requestToken; projection.value = null; isLoading.value = false; @@ -47,7 +48,13 @@ export const useExtractionsViewModel = (workspaceIdOverride?: string | null) => return Promise.resolve(); } - if (inFlight && inFlight.workspaceId === id) { + // The dedupe guard requires *currency*, not just a matching id. A superseded call (one + // whose token no longer matches, e.g. because the null-id branch above bumped the token + // on a deselect) can still be in flight for this same workspace id: deselect-then- + // reselect the same workspace fast enough and an id-only match would hand back that + // stale promise, which then fails its own `token === requestToken` check, assigns no + // projection, and leaves the page on the empty state for a workspace that has data. + if (inFlight && inFlight.workspaceId === id && inFlight.token === requestToken) { return inFlight.promise; } diff --git a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts index bf89039b8..b987cdfa8 100644 --- a/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts +++ b/extralit-frontend/v2/domain/entities/projection/grid-adapter.ts @@ -111,6 +111,15 @@ export function bandParity(projection: WorkspaceProjection): number[] { /** * Guards the §3.3 click-to-annotate affordance off until annotation-mode resolves v2 schema * ids (see ledger §5). Flip to true once that lands. + * + * DELIBERATELY RETAINED AHEAD OF ITS CONSUMER — do not delete as dead code. The whole chain + * (`cellAt`, `buildAnnotationUrl`, the grid's `cell-click` emit and its `composedPath` + * listener, the page binding, and `onCellClick`) was removed once as unreachable and then + * restored by an explicit revert, because it is the wiring the record-correction loop plugs + * into rather than speculative generality. That consumer is tracked as ENG-32 ("Cell/record + * correction loop in the extraction viewer"); flipping this flag plus resolving v2 schema + * ids in annotation-mode is the remaining work. Until then the click resolves a URL and + * intentionally does not navigate — see `useExtractionsViewModel.onCellClick`. */ export const ANNOTATION_CELL_LINKS_ENABLED = false; From 50d4d2a2e6b61bac26548158f378f0c3ea3ba0f8 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sat, 25 Jul 2026 15:37:31 -0700 Subject: [PATCH 32/43] docs: prune discarded goals from v2 specs/plans; drop a vacuous test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four parallel audits against HEAD classified doc content as discarded (abandoned/reversed and now contradicted by code), outdated (names code that does not exist), or vacuous (cannot fail). Removals only — no rewrites. Every finding was verified against `file:line` evidence before deletion. Discarded — the reference-review vertical (deleted by 293466ae8, superseded by the server-enriched projection + /extractions grid): - design spec §7 "Decision 5 — Reference-agnostic ProjectionReviewForm"; its ledger item shipped as GET /v2/projection. The 3 contract gotchas it recorded survive in 2026-07-20 §2, the other 2 in the plan (line 22) and apiErrors.ts. - plan Task 15 (/references page), the ReviewRecordCard + ProjectionReviewForm test blocks, e2e scenarios 2-4 and their seam-C preamble, the 9 dead review.* i18n keys, and the file-structure entries for all of the above. Discarded — the /extractions nav decision, reversed by b8651b0c8: - both spots forbidding nav wiring (plan Tasks 10/13) and the §5 ledger entry listing it as unbuilt; extractions-nav.spec.ts now gates the opposite. Discarded — the perspective.worker() calling convention, replaced by the memoized initPerspectiveClient (146f5ff43) and prohibited in-code: the two prose references plus the __mocks__ and vi.mock blocks encoding it. Vacuous: - AC4's "reference repeats rather than being merged" — guaranteed three times over (required Pydantic field, unconditional adapter line, and Perspective having no cell-merging at all), so nothing could violate it. - AC3's "no assertion is made about cross-schema row correspondence" — a constraint on the test author, not the system; it also contradicted the closing section, which claimed the criterion asserts it. - the e2e scenario-1 "CORS preflight" claim — Nitro's devProxy makes every /api/v2 call same-origin, so no preflight is ever issued. - plan Task 12's e2e listing, whose assertions 9ab8d5242 already replaced for being unfalsifiable, and Task 9's chunk-size check that Task 13 never runs. - useExtractionsViewModel's "does not navigate (guard off)" test: its only assertion duplicated grid-adapter.test.ts, and its actual claim was unasserted — flipping the flag or deleting the guard both left it green. 914 unit tests pass, lint clean, `nuxi typecheck` exit 0. --- .../2026-07-10-v2-frontend-vertical-slice.md | 640 +----------------- .../plans/2026-07-20-extraction-table.md | 101 +-- ...07-09-v2-frontend-vertical-slice-design.md | 109 +-- .../2026-07-20-extraction-table-design.md | 4 +- ...-07-24-extraction-projection-acceptance.md | 8 +- .../useExtractionsViewModel.test.ts | 11 +- 6 files changed, 27 insertions(+), 846 deletions(-) diff --git a/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md b/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md index 8c23bc7b2..ba4875e06 100644 --- a/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md +++ b/docs/superpowers/plans/2026-07-10-v2-frontend-vertical-slice.md @@ -17,7 +17,7 @@ - **Reuse-don't-fork:** leaves are extracted with `git mv` and v1 re-pointed; nothing is copy-pasted into `v2/`. - **Name-keyed global registries:** ts-injecty DI and `useStoreFor` Pinia stores are keyed by **class name**; Nuxt component auto-import is **flat** (`pathPrefix: false`). Every v2 use-case/repository/storage-state class name and every new component name must be globally unique vs v1 (hence `V2RecordRepository`, `V2TableEditor`, etc.). - **UI copy says "Schema"** — all new copy through i18n key families `schemas.*` and `review.*` in `translation/en.js` (fallbackLocale is `en`; other locales fall back). Never reuse `dataset`-keyed copy. -- **Routes:** `/schemas`, `/schemas/[id]`, `/schemas/[id]/settings`, `/references/[...reference]?workspace_id=`. No `/v2/` prefix. Always `encodeURIComponent(reference)` when building API URLs. +- **Routes:** `/schemas`, `/schemas/[id]`, `/schemas/[id]/settings`. No `/v2/` prefix. Always `encodeURIComponent(reference)` when building API URLs. - **axios baseURL is `/api`** → repository paths start with `/v2/...`. The shared instance injects `Authorization: Bearer` and runs `AxiosErrorHandler` + `AxiosCache` interceptors — do not create a parallel HTTP client. - **Server contract gotchas** (from spec §7/§10, verified against merged code): projection cells & `response.values` keyed by question **name**, suggestions by question **id**; `response.values` is double-wrapped `{question_name: {"value": …}}` on PUT and GET; `GET /records/{id}/responses` returns `null` with 200; two 422 body shapes (`detail: string` and `detail: [{loc,msg,type}]`); `total` is approximate; unknown filter columns → 5xx (UI must offer only known columns); `span` questions are rejected by the server (excluded from the slice). - **Question value shapes the server validates** (v1 validator union): `text`→string, `label_selection`→string, `multi_label_selection`→string[], `rating`→number, `ranking`→`[{value, rank}]`, `table`→object with keys ⊆ bound columns. @@ -30,9 +30,7 @@ 2. **App home:** nav sibling — a third "Schemas" tab on the home page that navigates to `/schemas` (Task 6). Home swap deferred to Phase 6. 3. **i18n:** new `schemas.*` / `review.*` families in `translation/en.js` only (fallback covers de/es/ja). 4. **Rebuild-index affordance:** yes — button on `/schemas/[id]/settings` calling `POST /v2/schemas/{id}:rebuild-index`, showing the returned `{indexed: n}` (Task 8). -5. **Orphaned response values:** `ReferenceReview` assembly collects `orphanedValues` (response keys no question owns); the form surfaces them read-only and excludes them from emit payloads (Tasks 11, 14). -**Spec deviation (recorded):** spec §7 sketches `` with a singular `draft` prop, but a reference spans multiple records each with its own draft. The plan puts each record's draft inside `ReviewRecord.draft` on the assembled entity; the form takes only `:review`. Emits keep the spec'd `(recordId, values)` signatures. ## File structure @@ -51,13 +49,12 @@ extralit-frontend/ record/V2Record.ts, record/RecordsPage.ts # Task 5 search/SearchCriteria.ts (+ .test.ts) # Task 5 review/response-values.ts (+ .test.ts) # Task 10 - review/ReferenceReview.ts, review/SuggestionHint.ts (+ tests) # Task 11 + review/SuggestionHint.ts (+ tests) # Task 11 review/widget-adapters.ts (+ .test.ts) # Task 14 domain/usecases/ get-schemas-use-case.ts, get-schema-settings-use-case.ts # Task 4 get-schema-records-use-case.ts, search-records-use-case.ts, rebuild-schema-index-use-case.ts # Task 5 - get-reference-review-use-case.ts (+ .test.ts) # Task 11 submit-reference-review-use-case.ts, save-review-draft-use-case.ts, discard-review-use-case.ts (+ tests) # Task 12 infrastructure/ @@ -67,7 +64,6 @@ extralit-frontend/ repositories/AnnotationRepository.ts, ProjectionRepository.ts, repositories/apiErrors.ts (+ tests) # Task 10 storage/SchemasStorage.ts # Task 4 - storage/ReferenceReviewsStorage.ts # Task 11 di/di.ts, di/index.ts # Task 4 (grows each task) components/base/inputs/ # Task 9 (moved leaves) label-selection/{LabelSelection.component.vue, useLabelSelectionViewModel.ts} @@ -76,13 +72,11 @@ extralit-frontend/ text-area/ContentEditableFeedbackTask.vue components/v2/ schemas/V2RecordsTable.vue # Task 7 - review/{ProjectionReviewForm.vue, ReviewRecordCard.vue, - ReviewCellInput.vue, ReviewProvenance.vue} (+ tests) # Task 14 + review/{ReviewCellInput.vue, ReviewProvenance.vue} (+ tests) # Task 14 table/V2TableEditor.vue (+ .test.ts) # Task 13 pages/schemas/index.vue + useSchemasViewModel.ts (+ test) # Task 6 pages/schemas/[id]/index.vue + useSchemaRecordsViewModel.ts (+ test) # Task 7 pages/schemas/[id]/settings.vue + useSchemaSettingsViewModel.ts (+ test)# Task 8 - pages/references/[...reference].vue + useReferenceReviewViewModel.ts # Task 15 e2e/v2/ # Tasks 16–17 fixtures.ts, seed/seed_v2_e2e.py, *.spec.ts translation/en.js # Tasks 6, 8, 14 (add keys) @@ -3378,20 +3372,15 @@ git commit -m "feat(v2-ui): lean V2TableEditor tabulator wrapper for table quest **Files:** - Create: `extralit-frontend/v2/domain/entities/review/widget-adapters.ts` -- Create: `extralit-frontend/components/v2/review/ProjectionReviewForm.vue` -- Create: `extralit-frontend/components/v2/review/ReviewRecordCard.vue` - Create: `extralit-frontend/components/v2/review/ReviewCellInput.vue` - Create: `extralit-frontend/components/v2/review/ReviewProvenance.vue` - Modify: `extralit-frontend/translation/en.js` (add `review.*` keys) - Test: `extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts` -- Test: `extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts` **Interfaces:** - Consumes: `ReferenceReview` / `ReviewRecord` / `ReviewCell` / `SuggestionHint` (Task 11), extracted leaves (Task 9), `V2TableEditor` (Task 13), `Question` (Task 3). - Produces: - Widget adapters (pure, tested): `buildLabelOptions(question, selected: unknown): {id,text,value,description,isSelected}[]`; `selectedFromLabelOptions(options, multiple): string | string[] | null`; `buildRatingOptions(question, selected: unknown): {id,value,isSelected}[]`; `selectedFromRatingOptions(options): number | null`; `buildRankingValues(question, ranked: unknown): {id,text,value,description,rank}[]`; `rankingAnswerFromValues(values): {value,rank}[]`; `suggestionHintFor(cell: ReviewCell): SuggestionHint | null`. - - `` — **pure**: no route reads, no fetches, no queue knowledge (spec §7). `values` are PLAIN (unwrapped); the page's use-cases wrap them. - - `` also accepts `:submit-errors="Record"` (recordId → messages) so the page can surface normalized 422s inline. - [ ] **Step 1: Write the failing adapter tests** @@ -3585,120 +3574,12 @@ In `extralit-frontend/translation/en.js`, add: ```js review: { - title: "Review", - submit: "Submit", - saveDraft: "Save draft", - discard: "Discard", suggestion: "Suggestion", response: "Response", agent: "Agent", score: "Score", - context: "Context", - notApplicable: "Not applicable in this schema version", - orphanedValues: "Values from removed questions (read-only, not resubmitted)", - loadError: "Could not load review for this reference.", - submitted: "Response submitted", - draftSaved: "Draft saved", - discarded: "Response discarded", - }, ``` -- [ ] **Step 4: Write the failing form component test** - -Create `extralit-frontend/components/v2/review/ProjectionReviewForm.test.ts`: - -```ts -import { describe, expect, it } from "vitest"; -import { mount } from "@vue/test-utils"; -import ProjectionReviewForm from "./ProjectionReviewForm.vue"; -import { ReferenceReview, ReviewCell, ReviewRecord } from "~/v2/domain/entities/review/ReferenceReview"; -import { Question } from "~/v2/domain/entities/question/Question"; - -const textQuestion = new Question("q-size", "s-1", "size", "Sample size", null, "text", ["size"], {}, true); -const labelQuestion = new Question("q-label", "s-1", "label", "Label", null, "label_selection", ["label"], { - type: "label_selection", - options: [{ value: "a", text: "A", description: null }], -}, false); - -const makeReview = (cells: ReviewCell[], draft = null, orphaned: { name: string; value: unknown }[] = []) => - new ReferenceReview("10.1000/j.x", [new ReviewRecord("r-1", "s-1", "sample_size", cells, [], orphaned, draft)], 1); - -const stubs = { - // Leaves are exercised in their own suites; here we assert dispatch + emit shaping. - ContentEditableFeedbackTask: { template: "
", props: ["value"] }, - LabelSelectionComponent: { template: "
", props: ["modelValue"] }, - RatingMonoSelectionComponent: true, - DndSelectionComponent: true, - V2TableEditor: true, -}; - -describe("ProjectionReviewForm", () => { - it("renders a widget per question type and suggestion provenance", () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), - new ReviewCell(labelQuestion, null, null, null, false), - ]); - - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.find(".stub-text").exists()).toBe(true); - expect(wrapper.find(".stub-label").exists()).toBe(true); - expect(wrapper.text()).toContain("review.suggestion"); - expect(wrapper.text()).toContain("gpt"); - }); - - it("emits submit with (recordId, plain values) — page wraps them", async () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", { agent: "gpt", score: 0.9, suggestedValue: "12" }, false), - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - await wrapper.get("[data-test='submit-r-1']").trigger("click"); - - expect(wrapper.emitted("submit")).toEqual([["r-1", { size: "12" }]]); - }); - - it("marks not-applicable cells and excludes them from emitted values", async () => { - const review = makeReview([ - new ReviewCell(textQuestion, "12", "suggestion", null, false), - new ReviewCell(labelQuestion, "a", "suggestion", null, true), - ]); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.text()).toContain("review.notApplicable"); - await wrapper.get("[data-test='save-draft-r-1']").trigger("click"); - expect(wrapper.emitted("save-draft")).toEqual([["r-1", { size: "12" }]]); - }); - - it("surfaces orphaned values read-only and never includes them in emits", async () => { - const review = makeReview( - [new ReviewCell(textQuestion, "12", null, null, false)], - null, - [{ name: "ghost", value: "zzz" }] - ); - const wrapper = mount(ProjectionReviewForm, { props: { review }, global: { stubs } }); - - expect(wrapper.text()).toContain("review.orphanedValues"); - expect(wrapper.text()).toContain("ghost"); - await wrapper.get("[data-test='submit-r-1']").trigger("click"); - expect(wrapper.emitted("submit")![0][1]).not.toHaveProperty("ghost"); - }); - - it("emits discard with the record id and renders submit errors passed back by the page", async () => { - const review = makeReview([new ReviewCell(textQuestion, null, null, null, false)]); - const wrapper = mount(ProjectionReviewForm, { - props: { review, submitErrors: { "r-1": ["missing value for required question: size"] } }, - global: { stubs }, - }); - - expect(wrapper.text()).toContain("missing value for required question: size"); - await wrapper.get("[data-test='discard-r-1']").trigger("click"); - expect(wrapper.emitted("discard")).toEqual([["r-1"]]); - }); -}); -``` - -Run: `npx vitest run components/v2/review --reporter=verbose` — expected FAIL (component missing). - [ ] **Step 5: Write the components** @@ -3904,174 +3785,6 @@ export default defineComponent({ ``` -Create `extralit-frontend/components/v2/review/ReviewRecordCard.vue`: - -```vue - - - - - -``` - -Note: `ReviewRecordCard` gets the pinned version's `columnsCache` (for table sub-column editors) from `record.columnsCache`, which Task 11's `ReviewRecord` already carries (last constructor param, defaulted to `[]` so this task's test fixtures can omit it). - -Create `extralit-frontend/components/v2/review/ProjectionReviewForm.vue`: - -```vue - - - -``` - [ ] **Step 6: Run tests to verify they pass** @@ -4087,232 +3800,6 @@ git commit -m "feat(v2-ui): pure ProjectionReviewForm with per-type widgets, pro --- -### Task 15: `/references/[...reference]` page + view-model - -**Files:** -- Create: `extralit-frontend/pages/references/[...reference].vue` -- Create: `extralit-frontend/pages/references/useReferenceReviewViewModel.ts` -- Test: `extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts` - -**Interfaces:** -- Consumes: `GetReferenceReviewUseCase` (Task 11); `SubmitReferenceReviewUseCase` / `SaveReviewDraftUseCase` / `DiscardReviewUseCase` / `ReviewSubmitError` (Task 12); `useReferenceReviews` (Task 11); `ProjectionReviewForm` (Task 14); `useNotifications` (v1 services, allowed). -- Produces: route `/references/?workspace_id=`; `useReferenceReviewViewModel(reference: string, workspaceId: string): { review, isLoading, loadFailed, submitErrors, onSubmit, onSaveDraft, onDiscard }`. The page is deliberately the *first thin wrapper* around the form (route param in, composable + form, nothing else — spec §7). - -- [ ] **Step 1: Write the failing view-model test** - -Create `extralit-frontend/pages/references/useReferenceReviewViewModel.test.ts`: - -```ts -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createPinia, setActivePinia } from "pinia"; -import { useResolveMock } from "~/v1/di/__mocks__/useResolveMock"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; -import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; -import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; -import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; -import { useReferenceReviewViewModel } from "./useReferenceReviewViewModel"; - -vi.mock("~/v1/infrastructure/services/useNotifications", () => ({ - useNotifications: () => ({ notify: vi.fn() }), -})); -vi.mock("~/v1/infrastructure/services/useTranslate", () => ({ - useTranslate: () => ({ t: (key: string) => key, tc: (key: string) => key }), -})); - -const REVIEW = new ReferenceReview("10.1000/j.x", [], 0); - -describe("useReferenceReviewViewModel", () => { - beforeEach(() => setActivePinia(createPinia())); - - it("loads the review on mount-equivalent call and exposes it", async () => { - const execute = vi.fn(async () => REVIEW); - useResolveMock(GetReferenceReviewUseCase, { execute }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn() }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.load(); - - expect(execute).toHaveBeenCalledWith("10.1000/j.x", "w-1"); - expect(vm.review.value).toBe(REVIEW); - }); - - it("collects normalized 422 messages per record on submit failure, then clears on success", async () => { - useResolveMock(GetReferenceReviewUseCase, { execute: vi.fn(async () => REVIEW) }); - const submit = vi - .fn() - .mockRejectedValueOnce(new ReviewSubmitError(["missing value for required question: size"], 422)) - .mockResolvedValueOnce({ id: "resp" }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: submit }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.onSubmit("r-1", {}); - expect(vm.submitErrors.value["r-1"]).toEqual(["missing value for required question: size"]); - - await vm.onSubmit("r-1", { size: "12" }); - expect(vm.submitErrors.value["r-1"]).toBeUndefined(); - }); - - it("reloads the review after a successful submit so the projection flips to response", async () => { - const load = vi.fn(async () => REVIEW); - useResolveMock(GetReferenceReviewUseCase, { execute: load }); - useResolveMock(SubmitReferenceReviewUseCase, { execute: vi.fn(async () => ({ id: "resp" })) }); - useResolveMock(SaveReviewDraftUseCase, { execute: vi.fn() }); - useResolveMock(DiscardReviewUseCase, { execute: vi.fn() }); - - const vm = useReferenceReviewViewModel("10.1000/j.x", "w-1"); - await vm.load(); - await vm.onSubmit("r-1", { size: "12" }); - - expect(load).toHaveBeenCalledTimes(2); - }); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd extralit-frontend && npx vitest run pages/references --reporter=verbose` -Expected: FAIL. - -- [ ] **Step 3: Write the view-model and page** - -Create `extralit-frontend/pages/references/useReferenceReviewViewModel.ts`: - -```ts -import { ref } from "vue"; -import { useResolve } from "ts-injecty"; -import { GetReferenceReviewUseCase } from "~/v2/domain/usecases/get-reference-review-use-case"; -import { SubmitReferenceReviewUseCase, ReviewSubmitError } from "~/v2/domain/usecases/submit-reference-review-use-case"; -import { SaveReviewDraftUseCase } from "~/v2/domain/usecases/save-review-draft-use-case"; -import { DiscardReviewUseCase } from "~/v2/domain/usecases/discard-review-use-case"; -import { ReferenceReview } from "~/v2/domain/entities/review/ReferenceReview"; -import { useNotifications } from "~/v1/infrastructure/services/useNotifications"; -import { useTranslate } from "~/v1/infrastructure/services/useTranslate"; - -export const useReferenceReviewViewModel = (reference: string, workspaceId: string) => { - const getReviewUseCase = useResolve(GetReferenceReviewUseCase); - const submitUseCase = useResolve(SubmitReferenceReviewUseCase); - const saveDraftUseCase = useResolve(SaveReviewDraftUseCase); - const discardUseCase = useResolve(DiscardReviewUseCase); - const notifications = useNotifications(); - const { t } = useTranslate(); - - const review = ref(null); - const isLoading = ref(false); - const loadFailed = ref(false); - const submitErrors = ref>({}); - - const load = async () => { - isLoading.value = true; - loadFailed.value = false; - try { - review.value = await getReviewUseCase.execute(reference, workspaceId); - } catch { - loadFailed.value = true; - } finally { - isLoading.value = false; - } - }; - - const runAction = async (recordId: string, action: () => Promise, successKey: string) => { - try { - await action(); - const { [recordId]: _cleared, ...rest } = submitErrors.value; - submitErrors.value = rest; - notifications.notify({ message: t(successKey), type: "success" }); - await load(); // re-read: projection source flips response/suggestion server-side - } catch (error) { - if (error instanceof ReviewSubmitError) { - submitErrors.value = { ...submitErrors.value, [recordId]: error.messages }; - } else { - throw error; - } - } - }; - - const onSubmit = (recordId: string, values: Record) => - runAction(recordId, () => submitUseCase.execute(recordId, values), "review.submitted"); - const onSaveDraft = (recordId: string, values: Record) => - runAction(recordId, () => saveDraftUseCase.execute(recordId, values), "review.draftSaved"); - const onDiscard = (recordId: string) => - runAction(recordId, () => discardUseCase.execute(recordId), "review.discarded"); - - return { review, isLoading, loadFailed, submitErrors, load, onSubmit, onSaveDraft, onDiscard }; -}; -``` - -Create `extralit-frontend/pages/references/[...reference].vue`: - -```vue - - - - - -``` - -- [ ] **Step 4: Run tests and full suite** - -Run: `cd extralit-frontend && npx vitest run pages/references --reporter=verbose && npm run test` -Expected: green. - -- [ ] **Step 5: Live smoke against the local stack (optional but recommended)** - -With the server stack up (`docker-compose up -d` at repo root; `cd extralit-server && uv run python -m extralit_server server-dev`), run `npm run dev`, seed a schema/records via the SDK or the Task 16 seed script, and open `/references/?workspace_id=`. Verify the form renders and a submit round-trips. - -- [ ] **Step 6: Commit** - -```bash -git add pages/references -git commit -m "feat(v2-ui): reference review page wrapping ProjectionReviewForm via composable" -``` - --- ### Task 16: e2e infrastructure (remote-chromium project, seed script) + scenarios 1–2 @@ -4322,7 +3809,6 @@ git commit -m "feat(v2-ui): reference review page wrapping ProjectionReviewForm - Create: `extralit-frontend/e2e/v2/fixtures.ts` - Create: `extralit-frontend/e2e/v2/seed/seed_v2_e2e.py` - Create: `extralit-frontend/e2e/v2/auth-smoke.spec.ts` (scenario 1) -- Create: `extralit-frontend/e2e/v2/slashed-reference.spec.ts` (scenario 2) - Create: `extralit-frontend/e2e/v2/README.md` (how to run on this host) **Interfaces:** @@ -4572,35 +4058,6 @@ test("signs in with a bearer token, lists schemas, opens records", async ({ page Note: the schema list loads for the *selected workspace* — if the seeded workspace isn't the first, switch to it via the workspace selector before asserting (check the home header UI for the selector; add the interaction here once visible). -- [ ] **Step 5: Scenario 2 — slashed-DOI reference** - -Create `extralit-frontend/e2e/v2/slashed-reference.spec.ts`: - -```ts -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// Seam B (spec §10.1-B): %2F-encoded DOI through Nuxt devProxy + uvicorn, untested server-side -// for the projection route. Assert both v2 reference endpoints round-trip. -test("opens a reference containing a slash via the encoded URL", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - - const projectionRequest = page.waitForResponse( - (r) => r.url().includes("/api/v2/projection/references/") && r.request().method() === "GET" - ); - const recordsRequest = page.waitForResponse( - (r) => r.url().includes(`/api/v2/schemas/${seed.schemaId}/records`) && r.request().method() === "GET" - ); - - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - expect((await projectionRequest).status()).toBe(200); - expect((await recordsRequest).status()).toBe(200); - - await expect(page.getByText(seed.reference)).toBeVisible(); - await expect(page.locator("[data-question='size']")).toBeVisible(); -}); -``` - [ ] **Step 6: README + run** @@ -4638,102 +4095,13 @@ git commit -m "test(v2-ui): e2e infra (CDP remote chromium, API seeding) + auth ### Task 17: e2e scenarios 3–5 (review loop, draft lifecycle, search round-trip) **Files:** -- Create: `extralit-frontend/e2e/v2/review-loop.spec.ts` (scenario 3) -- Create: `extralit-frontend/e2e/v2/draft-lifecycle.spec.ts` (scenario 4) - Create: `extralit-frontend/e2e/v2/search-roundtrip.spec.ts` (scenario 5) **Interfaces:** - Consumes: Task 16 fixtures/seed (`loadSeed`, `signIn`, seeded suggestion on question `size` with agent `e2e-seeder`), Task 14 form DOM contract (`[data-question]`, `[data-test='submit-']`, provenance badges rendering `review.suggestion`/`review.response` copy — i.e. "Suggestion"/"Response" in English). - Produces: the remaining slice-gating scenarios (spec §10.2 items 3–5). Scenarios 6–8 are follow-ups, out of this plan. -**IMPORTANT — verify seam C server behavior FIRST (spec §10.1-C):** the draft/discard response lifecycle has zero server tests. Before writing scenario 4's assertions, run the three `curl`s below against the live stack and confirm: (a) a `draft` response does NOT flip the projection cell (`source` stays `suggestion`), (b) `discarded` reverts a previously submitted cell to the suggestion. If either fails, STOP and report upstream — file it in the spec ledger; do not code the frontend around broken semantics. - -```bash -TOKEN=$(curl -s -X POST http://localhost:6900/api/v2/token -d "username=extralit&password=12345678" | jq -r .access_token) -RID=$(jq -r .recordId e2e/v2/seed/seed-output.json) -curl -s -X PUT "http://localhost:6900/api/v2/records/$RID/responses" -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" -d '{"values": {"size": {"value": "999"}}, "status": "draft"}' -curl -s "http://localhost:6900/api/v2/projection/references/10.1000%2Fj.e2e-v2?workspace_id=$(jq -r .workspaceId e2e/v2/seed/seed-output.json)" \ - -H "Authorization: Bearer $TOKEN" | jq '.records[0].cells[] | select(.question_name=="size")' -# expect: value "120", source "suggestion" (draft must NOT project) -``` - -- [ ] **Step 1: Scenario 3 — suggestion→response conversion loop** - -Create `extralit-frontend/e2e/v2/review-loop.spec.ts`: - -```ts -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// The core product loop (spec §10.2-3): suggestion shown with provenance → edit → submit → -// projection re-read flips source to response. Never chained over HTTP in the server suites. -test("converts a suggestion into a submitted response", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - const sizeCell = page.locator("[data-question='size']"); - await expect(sizeCell).toBeVisible(); - await expect(sizeCell.getByText("Suggestion")).toBeVisible(); - await expect(sizeCell.getByText("e2e-seeder")).toBeVisible(); - - // Edit the text answer (ContentEditableFeedbackTask renders a contenteditable paragraph). - const editor = sizeCell.locator("[contenteditable]"); - await editor.click(); - await editor.fill("135"); - - const putResponse = page.waitForResponse( - (r) => r.url().includes(`/api/v2/records/${seed.recordId}/responses`) && r.request().method() === "PUT" - ); - await page.locator(`[data-test='submit-${seed.recordId}']`).click(); - expect((await putResponse).status()).toBe(200); - // Reload: the projection must now resolve from the submitted response. - await page.reload(); - await expect(sizeCell.getByText("Response")).toBeVisible(); - await expect(sizeCell.getByText("Suggestion")).not.toBeVisible(); -}); -``` - -(If `contenteditable.fill` is flaky over CDP, use `editor.click()` + `page.keyboard` select-all/type. Reset state for re-runs by re-running the seed script — it recreates the schema.) - -- [ ] **Step 2: Scenario 4 — draft lifecycle** - -Create `extralit-frontend/e2e/v2/draft-lifecycle.spec.ts`: - -```ts -import { expect, loadSeed, signIn, test } from "./fixtures"; - -// Seam C (spec §10.1-C): drafts have ZERO server-side tests. This spec is the gate: -// a draft restores into the form on reload while the projection still shows the suggestion; -// submitting then flips the projection to response. -test("draft persists in the form without touching the projection, then submits", async ({ page }) => { - const seed = loadSeed(); - await signIn(page); - await page.goto(`/references/${encodeURIComponent(seed.reference)}?workspace_id=${seed.workspaceId}`); - - const sizeCell = page.locator("[data-question='size']"); - const editor = sizeCell.locator("[contenteditable]"); - await editor.click(); - await editor.fill("777"); - - const draftPut = page.waitForResponse( - (r) => r.url().includes(`/records/${seed.recordId}/responses`) && r.request().method() === "PUT" - ); - await page.locator(`[data-test='save-draft-${seed.recordId}']`).click(); - expect((await draftPut).status()).toBe(200); - - await page.reload(); - // Form restores the draft value... - await expect(sizeCell.locator("[contenteditable]")).toHaveText("777"); - // ...but the projection still resolves the suggestion (draft must not project). - await expect(sizeCell.getByText("Suggestion")).toBeVisible(); - - await page.locator(`[data-test='submit-${seed.recordId}']`).click(); - await page.reload(); - await expect(sizeCell.getByText("Response")).toBeVisible(); -}); -``` - [ ] **Step 3: Scenario 5 — search round-trip** @@ -4793,13 +4161,11 @@ git commit -m "test(v2-ui): e2e review loop, draft lifecycle and search round-tr - [ ] `cd extralit-server && uv run pytest tests/unit --disable-warnings` — green. - [ ] `cd extralit-frontend && npm run gen:api && git diff --exit-code -- v2/infrastructure/api` — no drift. - [ ] `cd extralit-frontend && npm run test && npm run lint && npx nuxi typecheck` — green (typecheck may surface pre-existing v1 issues; only new `v2/`/`components/v2` errors block). -- [ ] Full e2e: seed + scenarios 1–5 green against the live stack. - [ ] Boundary audit: `grep -rn "from \"[~@]/v1" extralit-frontend/v2/ | grep -v "store/create\|infrastructure/services"` → empty; `grep -rn "v2/" extralit-frontend/v1/` → empty. - [ ] Use superpowers:finishing-a-development-branch — PR against `develop`. ## Deferred / ledger (do not build now) - Scenarios 6–8 (multi-annotator isolation, old-version rendering e2e, required-422 rendering e2e) — follow the slice (spec §10.2). -- Server-side projection payload enrichment if the 5-endpoint `ReferenceReview` assembly proves chatty (spec §7 ledger). - Column filters on the schema detail page derived from `columns_cache` (only `status` ships now). - Markdown/table sub-modes of the text widget; span questions; schema authoring UI; Queue UI (Phase 5); v1 retirements (Phase 6). diff --git a/docs/superpowers/plans/2026-07-20-extraction-table.md b/docs/superpowers/plans/2026-07-20-extraction-table.md index 829982130..62a8818dc 100644 --- a/docs/superpowers/plans/2026-07-20-extraction-table.md +++ b/docs/superpowers/plans/2026-07-20-extraction-table.md @@ -1211,7 +1211,7 @@ Tasks 8–13, on `feat/v2-ui-extraction-grid`. This phase carries the integratio - Create: `extralit-frontend/__mocks__/perspective-bootstrap.js` **Interfaces:** -- Produces: `initPerspective(): Promise` — module-level singleton; resolves once WASM is initialized and returns the `@perspective-dev/client` default export (so callers do `const perspective = await initPerspective(); const client = await perspective.worker();`). Task 9 imports it via the alias-stable specifier `~/components/v2/extractions/perspective-bootstrap`. +- Produces: `initPerspective(): Promise` — module-level singleton; resolves once WASM is initialized and returns the `@perspective-dev/client` default export. Task 9 imports it via the alias-stable specifier `~/components/v2/extractions/perspective-bootstrap`. - The vitest stub replaces the whole bootstrap module, so unit tests never touch WASM/custom elements (mirrors the `tabulator-tables` mock precedent). - [ ] **Step 1: Install pinned packages** @@ -1299,21 +1299,6 @@ If `init_server`/`init_client` names differ in 4.5.2, check `node_modules/@persp - [ ] **Step 5: Create the vitest stub and alias it** -Create `__mocks__/perspective-bootstrap.js`: - -```js -// Perspective touches WASM + custom elements at import time; specs use this stub -// (same rationale as __mocks__/tabulator-tables.js). -export const initPerspective = async () => ({ - worker: async () => ({ - table: async (data) => ({ - __data: data, - size: async () => data.length, - delete: async () => undefined, - }), - }), -}); -``` In `vitest.config.ts`, add to the `resolve.alias` object ABOVE the `"~~"` entry (longer keys must win before `"~"` matches): @@ -1346,55 +1331,11 @@ git commit -m "feat(v2-ui): Perspective 4.5.2 deps, WASM bootstrap, Vite/vitest **Interfaces:** - Consumes: `initPerspective` (Task 8), `toPerspectiveData`/`cellAt`/`bandParity` (Task 6), `WorkspaceProjection` (Task 5). - Produces: `` (auto-imported by name — `components` config uses `pathPrefix: false`). Emits `cell-click` with `{ cell: ProjectionGridCell; reference: string; schemaId: string; columnName: string }`. Task 10's page listens to this. -- Lazy chunking (spec §3.3): Nuxt code-splits per page and auto-imported components bundle into the chunks that use them — since only `pages/extractions/index.vue` uses this component, the Perspective JS/WASM cost is paid only on `/extractions`. Verify in Task 13's `npm run build` output: the perspective modules must NOT appear in the entry chunk. +- Lazy chunking (spec §3.3): Nuxt code-splits per page and auto-imported components bundle into the chunks that use them — since only `pages/extractions/index.vue` uses this component, the Perspective JS/WASM cost is paid only on `/extractions`. - Note: the custom-element/regular-table interactions cannot be exercised under happy-dom; the unit test covers mount → bootstrap → table creation → viewer load. The real rendering gate is Task 12's e2e spec. - [ ] **Step 1: Write the failing component test** -Create `components/v2/extractions/ExtractionsGrid.test.ts`: - -```ts -import { flushPromises, mount } from "@vue/test-utils"; -import { describe, expect, it, vi } from "vitest"; -import ExtractionsGrid from "./ExtractionsGrid.vue"; -import { WorkspaceProjection } from "~/v2/domain/entities/projection/WorkspaceProjection"; - -const tableSpy = vi.fn(async (data: unknown) => ({ __data: data, delete: async () => undefined })); -const initSpy = vi.fn(async () => ({ worker: async () => ({ table: tableSpy }) })); - -vi.mock("~/components/v2/extractions/perspective-bootstrap", () => ({ - initPerspective: (...args: unknown[]) => initSpy(...args), -})); - -const PROJECTION = new WorkspaceProjection( - [{ name: "Design.type", schemaId: "s-1", schemaName: "Design", questionName: "type", subColumn: null, dtype: "text" }], - [ - { - reference: "10.1/a", - rowIndex: 0, - cells: { - "Design.type": { value: "RCT", source: "response", recordId: "r-1", agent: null, score: null }, - }, - }, - ], - 1 -); - -describe("ExtractionsGrid", () => { - it("boots perspective once and loads the flat projection rows into a table", async () => { - mount(ExtractionsGrid, { - props: { projection: PROJECTION }, - global: { - config: { compilerOptions: { isCustomElement: (tag: string) => tag.startsWith("perspective-") } }, - }, - }); - await flushPromises(); - - expect(initSpy).toHaveBeenCalledTimes(1); - expect(tableSpy).toHaveBeenCalledWith([{ reference: "10.1/a", "Design.type": "RCT" }]); - }); -}); -``` - [ ] **Step 2: Run to verify failure** @@ -1405,7 +1346,7 @@ Expected: FAIL — component file not found. Create `components/v2/extractions/ExtractionsGrid.vue` — ` - - diff --git a/extralit-frontend/components/v2/review/ReviewProvenance.test.ts b/extralit-frontend/components/v2/review/ReviewProvenance.test.ts deleted file mode 100644 index d2682e2e0..000000000 --- a/extralit-frontend/components/v2/review/ReviewProvenance.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve as resolvePath } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { mount } from "@vue/test-utils"; -import { createI18n } from "vue-i18n"; -import ReviewProvenance from "./ReviewProvenance.vue"; -// Real message catalog (not the `#key#`-echoing global test stub from test/setup.ts, -// which would make this regression test hollow: it never actually looks a key up, so it -// can never fail when a key is deleted from en.js). A real vue-i18n instance built from -// the real catalog falls back to rendering the raw key when a message is missing, which is -// exactly the silent regression this test needs to catch: ReviewProvenance.vue looks its -// key up dynamically — `$t(`review.${source}`)` — so a plain grep over en.js for the -// literal strings "review.response"/"review.suggestion" finds nothing, and the previous -// i18n cleanup deleted both keys as "unreferenced". -import en from "~/translation/en"; - -// Under this repo's Vitest/Vite pipeline (@nuxtjs/i18n's build-time transform), the -// imported `en` module is precompiled: leaf message strings become message-compiler AST -// nodes, not plain strings. That's fine for feeding into `createI18n` (vue-i18n resolves -// them the same way the running app does) but means `en.review.response` itself isn't a -// bare string to assert against here — hence the expected labels below are asserted -// literally, and separately cross-checked against the raw source text of en.js. -// -// The compiler also mutates its input messages object in place (caching compiled nodes -// over the original values); `en` is the real, singleton-cached module export, so mutating -// it here would corrupt every other spec that imports translation/en. Deep-clone first. -// `@nuxtjs/i18n`'s generated module augmentation types `messages` against every -// configured locale (en/de/es/ja) — this spec only needs `en`, so the rest are empty. -const i18n = createI18n({ - legacy: false, - locale: "en", - fallbackLocale: "en", - messages: { en: structuredClone(en), de: {}, es: {}, ja: {} }, -}); - -const mountProvenance = (source: "response" | "suggestion") => - mount(ReviewProvenance, { - props: { source, provenance: null }, - // Override the repo-wide `$t` -> `#key#` stub (test/setup.ts) with the real vue-i18n - // `t` resolved against the actual en.js catalog, for this mount only. - global: { mocks: { $t: i18n.global.t } }, - }); - -// Original values recovered verbatim from git (293466ae8^:extralit-frontend/translation/en.js). -const EXPECTED = { response: "Response", suggestion: "Suggestion" } as const; - -// Resolved relative to this spec file (not `process.cwd()`), so the check doesn't ENOENT when -// vitest is invoked from a different directory (e.g. the monorepo root) than -// `extralit-frontend/`. Deliberately avoids `new URL(relative, import.meta.url)`: this spec's -// `happy-dom` test environment overrides the global `URL` constructor (and `node:url`'s -// browser-shimmed `URL` re-export follows it) to resolve relative references against the -// fake `http://localhost:3000` test document location instead of the given `file:` base. -const en_js_path = resolvePath(dirname(fileURLToPath(import.meta.url)), "../../../translation/en.js"); -const en_js_source = readFileSync(en_js_path, "utf-8"); -// Scoped to the `review: { ... }` block specifically — matching against the whole file would -// also be satisfied by an identically-valued pair under any OTHER namespace (en.js already -// has a near-miss: `response: "Response value"` elsewhere), so it would never actually pin -// `review.suggestion`/`review.response`. -const reviewBlockMatch = en_js_source.match(/\breview:\s*{([^}]*)}/); -if (!reviewBlockMatch) { - throw new Error(`Could not locate a top-level "review: { ... }" block in ${en_js_path}`); -} -const reviewBlockSource = reviewBlockMatch[1]; - -describe("ReviewProvenance dynamic source lookup ($t(`review.${source}`))", () => { - it.each(["response", "suggestion"] as const)( - "resolves the %s provenance label from the real catalog, not the raw key", - (source) => { - const wrapper = mountProvenance(source); - - expect(wrapper.text()).toContain(EXPECTED[source]); - expect(wrapper.text()).not.toContain(`review.${source}`); - } - ); - - it("keeps both review.suggestion and review.response present in the imported catalog's structure", () => { - // Asserts against the imported object's actual structure (post `@nuxtjs/i18n` build-time - // transform) rather than only regexing source text, so a key removed from `en.review` — - // or moved to a differently-shaped export — fails here directly. - expect(Object.keys(en.review)).toEqual(expect.arrayContaining(["suggestion", "response"])); - }); - - it.each(["response", "suggestion"] as const)( - "keeps review.%s present with its expected value in the checked-in en.js catalog's review block", - (key) => { - // Belt-and-suspenders: assert directly against the source text, independent of the - // build-time i18n transform above (and scoped to the `review` block only, so it can't - // be satisfied by a same-valued key under a different namespace), so a future edit to - // en.js is caught even if the transform/mount plumbing above ever changes. - expect(reviewBlockSource).toMatch(new RegExp(`\\b${key}:\\s*"${EXPECTED[key]}"`)); - } - ); -}); diff --git a/extralit-frontend/components/v2/review/ReviewProvenance.vue b/extralit-frontend/components/v2/review/ReviewProvenance.vue deleted file mode 100644 index e367629a5..000000000 --- a/extralit-frontend/components/v2/review/ReviewProvenance.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - - - diff --git a/extralit-frontend/components/v2/table/V2TableEditor.test.ts b/extralit-frontend/components/v2/table/V2TableEditor.test.ts deleted file mode 100644 index 1e7daaeea..000000000 --- a/extralit-frontend/components/v2/table/V2TableEditor.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { defineComponent, h, ref } from "vue"; -import { flushPromises, mount } from "@vue/test-utils"; -import { TabulatorFull } from "tabulator-tables"; -import { ColumnMeta } from "~/v2/domain/entities/schema/ColumnMeta"; -import V2TableEditor, { tabulatorColumns, valueFromRowData } from "./V2TableEditor.vue"; - -// The vitest alias resolves tabulator-tables to __mocks__/tabulator-tables.js, which adds -// these static test hooks; the real @types don't declare them, so view them through a cast. -const MockTabulator = TabulatorFull as unknown as { - constructed: number; - latest: { emit(event: string, ...args: unknown[]): void } | null; -}; - -describe("V2TableEditor column derivation", () => { - it("derives one tabulator column per bound ColumnMeta with dtype-driven editors", () => { - const columns = tabulatorColumns( - [ - new ColumnMeta("name", "str", false, null), - new ColumnMeta("count", "int64", false, null), - new ColumnMeta("done", "bool", false, null), - new ColumnMeta("when", "datetime64[ns]", true, null), - ], - true - ); - - expect(columns.map((c) => [c.field, c.editor])).toEqual([ - ["name", "input"], - ["count", "number"], - ["done", "tickCross"], - ["when", "date"], - ]); - }); - - it("honors the review overlay hint over the dtype default", () => { - const columns = tabulatorColumns([new ColumnMeta("count", "int64", false, { type: "text" })], true); - expect(columns[0].editor).toBe("input"); - }); - - it("disables editors when not editable", () => { - const columns = tabulatorColumns([new ColumnMeta("name", "str", false, null)], false); - expect(columns[0].editor).toBe(false); - }); -}); - -describe("valueFromRowData", () => { - it("keeps only bound-column keys (server validates keys ⊆ bound columns)", () => { - const value = valueFromRowData({ name: "a", stray: "x" }, [new ColumnMeta("name", "str", false, null)]); - expect(value).toEqual({ name: "a" }); - }); - - it("drops undefined cells so absent keys stay absent", () => { - const value = valueFromRowData({ name: undefined }, [new ColumnMeta("name", "str", false, null)]); - expect(value).toEqual({}); - }); -}); - -describe("V2TableEditor rebuild behavior", () => { - beforeEach(() => { - MockTabulator.constructed = 0; - MockTabulator.latest = null; - }); - - // A v-model parent so a committed cell edit echoes back into modelValue, exercising - // the self-emit guard that must NOT rebuild the live tabulator (roborev job 148). - const Parent = defineComponent({ - setup() { - const value = ref>({ name: "a" }); - const columns = [new ColumnMeta("name", "str", false, null)]; - return () => - h(V2TableEditor, { - modelValue: value.value, - columns, - "onUpdate:modelValue": (v: Record) => (value.value = v), - }); - }, - }); - - it("does not rebuild the table when the parent echoes back a committed cell edit", async () => { - const wrapper = mount(Parent); - await flushPromises(); - expect(MockTabulator.constructed).toBe(1); - - // Simulate a committed cell edit: the stored handler emits the new row value, - // the parent writes it back into modelValue. - MockTabulator.latest.emit("cellEdited", { getRow: () => ({ getData: () => ({ name: "b" }) }) }); - await flushPromises(); - - // The value round-tripped, but the editor was not torn down and recreated. - expect(MockTabulator.constructed).toBe(1); - wrapper.unmount(); - }); - - it("rebuilds when modelValue changes externally (draft restore / discard)", async () => { - const value = ref>({ name: "a" }); - const columns = [new ColumnMeta("name", "str", false, null)]; - const wrapper = mount(V2TableEditor, { props: { modelValue: value.value, columns } }); - await flushPromises(); - expect(MockTabulator.constructed).toBe(1); - - await wrapper.setProps({ modelValue: { name: "external" } }); - await flushPromises(); - - expect(MockTabulator.constructed).toBe(2); - wrapper.unmount(); - }); -}); diff --git a/extralit-frontend/components/v2/table/V2TableEditor.vue b/extralit-frontend/components/v2/table/V2TableEditor.vue deleted file mode 100644 index a318522e2..000000000 --- a/extralit-frontend/components/v2/table/V2TableEditor.vue +++ /dev/null @@ -1,95 +0,0 @@ - - - - - diff --git a/extralit-frontend/translation/en.js b/extralit-frontend/translation/en.js index 2927ef754..3434d7de2 100644 --- a/extralit-frontend/translation/en.js +++ b/extralit-frontend/translation/en.js @@ -139,13 +139,6 @@ export default { noWorkspace: "Select a workspace to view its extraction table.", loadError: "Could not load the extraction table.", }, - review: { - suggestion: "Suggestion", - response: "Response", - agent: "Agent", - score: "Score", - notApplicable: "Not applicable in this schema version", - }, settings: { title: "Dataset settings", datasetInfo: "Dataset info", diff --git a/extralit-frontend/v2/domain/entities/review/ReviewCell.ts b/extralit-frontend/v2/domain/entities/review/ReviewCell.ts deleted file mode 100644 index b17d5a414..000000000 --- a/extralit-frontend/v2/domain/entities/review/ReviewCell.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Question } from "../question/Question"; - -export interface Provenance { - agent: string | null; - score: number | null; - suggestedValue: unknown; -} - -export class ReviewCell { - constructor( - public readonly question: Question, - public readonly value: unknown, - public readonly source: "response" | "suggestion" | null, - public readonly provenance: Provenance | null, - // The question binds a column absent from this record's pinned version cache (§17.3). - public readonly notApplicable: boolean - ) {} -} diff --git a/extralit-frontend/v2/domain/entities/review/SuggestionHint.test.ts b/extralit-frontend/v2/domain/entities/review/SuggestionHint.test.ts deleted file mode 100644 index 187057c6c..000000000 --- a/extralit-frontend/v2/domain/entities/review/SuggestionHint.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SuggestionHint } from "./SuggestionHint"; - -describe("SuggestionHint (leaf-widget suggestion duck-type)", () => { - it("matches scalar values for single-select widgets", () => { - const hint = new SuggestionHint("malaria", "gpt-4", 0.87, false); - - expect(hint.isSuggested("malaria")).toBe(true); - expect(hint.isSuggested("dengue")).toBe(false); - expect(hint.getSuggestion("malaria")).toEqual({ agent: "gpt-4", score: { fixed: "0.9" } }); - }); - - it("matches membership for multi-select widgets", () => { - const hint = new SuggestionHint(["a", "b"], null, null, true); - - expect(hint.isSuggested("a")).toBe(true); - expect(hint.isSuggested("c")).toBe(false); - expect(hint.getSuggestion("a")).toEqual({ agent: null, score: undefined }); - }); -}); diff --git a/extralit-frontend/v2/domain/entities/review/SuggestionHint.ts b/extralit-frontend/v2/domain/entities/review/SuggestionHint.ts deleted file mode 100644 index 9f8e003c0..000000000 --- a/extralit-frontend/v2/domain/entities/review/SuggestionHint.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Implements the duck-type the extracted leaf widgets consume as their `suggestion` prop: -// isSuggested(value) / getSuggestion(value) -> { agent, score?: { fixed } } (see Task 9 contract). -export class SuggestionHint { - constructor( - private readonly suggestedValue: unknown, - private readonly agent: string | null, - private readonly score: number | null, - private readonly multiple: boolean - ) {} - - isSuggested(value: unknown): boolean { - if (this.multiple && Array.isArray(this.suggestedValue)) { - return (this.suggestedValue as unknown[]).includes(value); - } - return this.suggestedValue === value; - } - - getSuggestion(value: unknown): { agent: string | null; score?: { fixed: string } } | undefined { - if (!this.isSuggested(value)) return undefined; - return { agent: this.agent, score: this.score != null ? { fixed: this.score.toFixed(1) } : undefined }; - } -} diff --git a/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts b/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts deleted file mode 100644 index 983cb0b57..000000000 --- a/extralit-frontend/v2/domain/entities/review/widget-adapters.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { Question } from "../question/Question"; -import { ReviewCell } from "./ReviewCell"; -import { - buildLabelOptions, - buildRankingValues, - buildRatingOptions, - rankingAnswerFromValues, - selectedFromLabelOptions, - selectedFromRatingOptions, - suggestionHintFor, -} from "./widget-adapters"; - -const labelQuestion = new Question( - "q-1", - "s-1", - "label", - "Label", - null, - "label_selection", - ["label"], - { - type: "label_selection", - options: [ - { value: "a", text: "A", description: null }, - { value: "b", text: "B", description: null }, - ], - }, - false -); - -const ratingQuestion = new Question( - "q-2", - "s-1", - "stars", - "Stars", - null, - "rating", - ["stars"], - { - type: "rating", - options: [{ value: 1 }, { value: 2 }, { value: 3 }], - }, - false -); - -const rankingQuestion = new Question( - "q-3", - "s-1", - "rank", - "Rank", - null, - "ranking", - ["rank"], - { - type: "ranking", - options: [ - { value: "x", text: "X", description: null }, - { value: "y", text: "Y", description: null }, - ], - }, - false -); - -describe("label adapters", () => { - it("builds leaf options with isSelected from a scalar (single) or array (multi) value", () => { - expect(buildLabelOptions(labelQuestion, "b").map((o) => o.isSelected)).toEqual([false, true]); - expect(buildLabelOptions(labelQuestion, ["a", "b"]).map((o) => o.isSelected)).toEqual([true, true]); - expect(buildLabelOptions(labelQuestion, null).map((o) => o.isSelected)).toEqual([false, false]); - }); - - it("derives the server value shape back from options", () => { - const options = buildLabelOptions(labelQuestion, "b"); - expect(selectedFromLabelOptions(options, false)).toBe("b"); - expect(selectedFromLabelOptions(options, true)).toEqual(["b"]); - expect(selectedFromLabelOptions(buildLabelOptions(labelQuestion, null), false)).toBeNull(); - }); -}); - -describe("rating adapters", () => { - it("round-trips a numeric rating", () => { - const options = buildRatingOptions(ratingQuestion, 2); - expect(options).toEqual([ - { id: "stars_1", value: 1, isSelected: false }, - { id: "stars_2", value: 2, isSelected: true }, - { id: "stars_3", value: 3, isSelected: false }, - ]); - expect(selectedFromRatingOptions(options)).toBe(2); - }); -}); - -describe("ranking adapters", () => { - it("round-trips the [{value, rank}] server shape", () => { - const values = buildRankingValues(rankingQuestion, [{ value: "y", rank: 1 }]); - expect(values.find((v) => v.value === "y")?.rank).toBe(1); - expect(values.find((v) => v.value === "x")?.rank).toBeNull(); - - values.find((v) => v.value === "x")!.rank = 2; - expect(rankingAnswerFromValues(values)).toEqual([ - { value: "y", rank: 1 }, - { value: "x", rank: 2 }, - ]); - }); -}); - -describe("suggestionHintFor", () => { - it("returns a hint only for suggestion-sourced cells", () => { - const suggested = new ReviewCell( - labelQuestion, - "a", - "suggestion", - { agent: "gpt", score: 0.5, suggestedValue: "a" }, - false - ); - const responded = new ReviewCell(labelQuestion, "a", "response", null, false); - - expect(suggestionHintFor(suggested)?.isSuggested("a")).toBe(true); - expect(suggestionHintFor(responded)).toBeNull(); - }); -}); diff --git a/extralit-frontend/v2/domain/entities/review/widget-adapters.ts b/extralit-frontend/v2/domain/entities/review/widget-adapters.ts deleted file mode 100644 index be08fbac2..000000000 --- a/extralit-frontend/v2/domain/entities/review/widget-adapters.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Question } from "../question/Question"; -import { type ReviewCell } from "./ReviewCell"; -import { SuggestionHint } from "./SuggestionHint"; - -// Adapters between server value shapes and the extracted leaf-widget option shapes -// (see Task 9 contracts). Ids follow v1's `${questionName}_${value}` convention. - -export interface LabelOption { - id: string; - text: string; - value: string; - description: string | null; - isSelected: boolean; -} - -export const buildLabelOptions = (question: Question, selected: unknown): LabelOption[] => { - const selectedValues = Array.isArray(selected) - ? (selected as string[]) - : selected != null - ? [selected as string] - : []; - return question.options.map((option) => ({ - id: `${question.name}_${option.value}`, - text: option.text, - value: option.value, - description: option.description, - isSelected: selectedValues.includes(option.value), - })); -}; - -export const selectedFromLabelOptions = (options: LabelOption[], multiple: boolean): string | string[] | null => { - const selected = options.filter((o) => o.isSelected).map((o) => o.value); - if (multiple) return selected; - return selected[0] ?? null; -}; - -export interface RatingOption { - id: string; - value: number; - isSelected: boolean; -} - -export const buildRatingOptions = (question: Question, selected: unknown): RatingOption[] => - question.ratingValues.map((value) => ({ - id: `${question.name}_${value}`, - value, - isSelected: selected === value, - })); - -export const selectedFromRatingOptions = (options: RatingOption[]): number | null => - options.find((o) => o.isSelected)?.value ?? null; - -export interface RankingValue { - id: string; - text: string; - value: string; - description: string | null; - rank: number | null; -} - -export const buildRankingValues = (question: Question, ranked: unknown): RankingValue[] => { - const ranks = new Map( - Array.isArray(ranked) ? (ranked as { value: string; rank: number }[]).map((r) => [r.value, r.rank]) : [] - ); - return question.options.map((option) => ({ - id: `${question.name}_${option.value}`, - text: option.text, - value: option.value, - description: option.description, - rank: ranks.get(option.value) ?? null, - })); -}; - -export const rankingAnswerFromValues = (values: RankingValue[]): { value: string; rank: number }[] => - values - .filter((v) => v.rank != null) - .sort((a, b) => (a.rank as number) - (b.rank as number)) - .map((v) => ({ value: v.value, rank: v.rank as number })); - -export const suggestionHintFor = (cell: ReviewCell): SuggestionHint | null => { - if (cell.source !== "suggestion" || !cell.provenance) return null; - return new SuggestionHint( - cell.provenance.suggestedValue, - cell.provenance.agent, - cell.provenance.score, - cell.question.type === "multi_label_selection" - ); -}; diff --git a/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts b/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts deleted file mode 100644 index 76032ccd3..000000000 --- a/extralit-frontend/v2/domain/entities/review/widget-mapping.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ColumnMeta } from "../schema/ColumnMeta"; -import { columnCellEditor, contextRenderer, dtypeDefaultEditor } from "./widget-mapping"; - -describe("dtypeDefaultEditor", () => { - it.each([ - ["str", "text"], - ["int64", "number"], - ["int32", "number"], - ["float64", "number"], - ["bool", "checkbox"], - ["datetime64[ns]", "date"], - ["object", "text"], // unknown dtype falls back to text - ["interval[int64]", "text"], // "int" prefix must not classify interval as number - ["uint8", "number"], - ])("maps dtype %s to %s", (dtype, editor) => { - expect(dtypeDefaultEditor(dtype)).toBe(editor); - }); -}); - -describe("columnCellEditor (§6.2 precedence)", () => { - it("uses review.type when it is a known editor", () => { - const column = new ColumnMeta("score", "float64", true, { type: "text" }); - expect(columnCellEditor(column)).toBe("text"); - }); - - it("falls back to dtype default when review.type is unknown (forward-compatible overlay)", () => { - const column = new ColumnMeta("score", "float64", true, { type: "sparkline" }); - expect(columnCellEditor(column)).toBe("number"); - }); - - it("falls back to dtype default when review is null", () => { - const column = new ColumnMeta("done", "bool", false, null); - expect(columnCellEditor(column)).toBe("checkbox"); - }); -}); - -describe("contextRenderer (§6.3)", () => { - it("mirrors the same precedence for read-only context fields", () => { - expect(contextRenderer(new ColumnMeta("when", "datetime64[ns]", true, null))).toBe("date"); - expect(contextRenderer(new ColumnMeta("when", "datetime64[ns]", true, { type: "text" }))).toBe("text"); - }); -}); diff --git a/extralit-frontend/v2/domain/entities/review/widget-mapping.ts b/extralit-frontend/v2/domain/entities/review/widget-mapping.ts deleted file mode 100644 index 6975b0634..000000000 --- a/extralit-frontend/v2/domain/entities/review/widget-mapping.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { type ColumnMeta } from "../schema/ColumnMeta"; - -// Cell editors for table-question sub-columns and read-only context fields (spec §6.2/§6.3). -// Question-level widgets (§6.1) come straight from question.type — see ReviewCellInput. -export type CellEditor = "text" | "number" | "checkbox" | "date"; - -const KNOWN_EDITORS: CellEditor[] = ["text", "number", "checkbox", "date"]; - -export const dtypeDefaultEditor = (dtype: string): CellEditor => { - // Require a digit after int/float so numpy widths (int64, uint8, float32) match but - // pandas `interval[...]` does not fall through to the number editor. - if (/^u?int\d/.test(dtype) || /^float\d/.test(dtype)) return "number"; - if (dtype === "bool") return "checkbox"; - if (dtype.startsWith("datetime")) return "date"; - return "text"; // str and anything unknown -}; - -export const columnCellEditor = (column: ColumnMeta): CellEditor => { - const hinted = column.review?.type; - if (hinted && (KNOWN_EDITORS as string[]).includes(hinted)) return hinted as CellEditor; - return dtypeDefaultEditor(column.dtype); -}; - -// Same precedence for non-question context fields; separate name so call sites read as §6.3. -export const contextRenderer = (column: ColumnMeta): CellEditor => columnCellEditor(column); From 367b911043c33bd2ee52775c76e874b1de1fefe8 Mon Sep 17 00:00:00 2001 From: JonnyTran Date: Sun, 26 Jul 2026 13:49:04 -0700 Subject: [PATCH 40/43] fix(v2-ui): make V2StatusBadge render real colors, and translate it Three defects, all shipped together: 1. The color map named --fg-status-active and --fg-status-danger. Neither exists in assets/css/themes.css - the defined tokens are --fg-status-{pending,draft,discarded,submitted}. Every entry fell through to its var() fallback, so every status rendered the same grey. `published` and `completed` have no token of their own and now share --fg-status-submitted: both are the terminal "done" state and they never appear in the same table (schemas renders draft|published, records renders pending|completed|discarded). 2. The badge rendered the raw server status string. It now translates via v2Status.*, a new key family covering both vocabularies the badge spans. Deliberately not v1's recordStatus.*, which is disjoint. 3. V2RecordsTable declared a required workspaceId prop that nothing referenced, left over from the reference-review page deleted by 293466ae8. Its call site passed `schema?.workspaceId ?? ''` - and Schema has no workspaceId field, so it was always ''. The old spec could not see defect 1: it compared the two *declaration strings*, which differ, while the rendered colors were identical. The rewrite asserts the resolved token per status AND that every token named is defined in all three of themes.css's blocks - the second check is what makes a nonexistent token visible. Verified by reintroducing the bug: both assertions fail. Tests 883 -> 899, lint and typecheck clean. --- .../v2/common/V2StatusBadge.test.ts | 79 +++++++++++++++++-- .../components/v2/common/V2StatusBadge.vue | 36 ++++++--- .../components/v2/schemas/V2RecordsTable.vue | 1 - .../pages/schemas/[id]/index.vue | 7 +- extralit-frontend/translation/en.js | 10 +++ 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/extralit-frontend/components/v2/common/V2StatusBadge.test.ts b/extralit-frontend/components/v2/common/V2StatusBadge.test.ts index 18519268c..da11efd7b 100644 --- a/extralit-frontend/components/v2/common/V2StatusBadge.test.ts +++ b/extralit-frontend/components/v2/common/V2StatusBadge.test.ts @@ -1,22 +1,85 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { mount } from "@vue/test-utils"; -import V2StatusBadge from "./V2StatusBadge.vue"; +import V2StatusBadge, { STATUS_TOKENS } from "./V2StatusBadge.vue"; const mountBadge = (status: string) => mount(V2StatusBadge, { props: { status }, global: { - stubs: { BaseBadge: { name: "BaseBadge", template: "{{ text }}", props: ["text", "color"] } }, + stubs: { + BaseBadge: { name: "BaseBadge", template: "{{ text }}", props: ["text", "color"] }, + }, + mocks: { $t: (key: string) => `t:${key}` }, }, }); -describe("V2StatusBadge", () => { - it("passes the status through as the badge text", () => { - expect(mountBadge("published").text()).toContain("published"); +const propOf = (status: string, prop: "color" | "text") => + mountBadge(status).findComponent({ name: "BaseBadge" }).props(prop); + +describe("V2StatusBadge colors", () => { + // The predecessor of this spec compared two *declaration strings* and asserted they + // differed. They did — but both named tokens that assets/css/themes.css never defined + // (--fg-status-active, --fg-status-danger), so both fell through to their var() fallback + // and every status rendered the same grey. Asserting the resolved token per status, plus + // that the token actually exists, is what makes that failure mode visible. + it.each([ + ["draft", "--fg-status-draft"], + ["published", "--fg-status-submitted"], + ["pending", "--fg-status-pending"], + ["completed", "--fg-status-submitted"], + ["discarded", "--fg-status-discarded"], + ])("maps %s to var(%s)", (status, token) => { + expect(propOf(status, "color")).toBe(`var(${token})`); + }); + + it("falls back to --fg-secondary for a status it does not know", () => { + expect(propOf("something-new", "color")).toBe("var(--fg-secondary)"); + }); + + it("gives the statuses that share a table visibly different colors", () => { + // Schemas table renders draft|published; records table renders pending|completed|discarded. + // Within each table every status must be distinguishable. Across tables, published and + // completed deliberately share the "done" hue. + const distinct = (statuses: string[]) => new Set(statuses.map((s) => propOf(s, "color"))).size; + + expect(distinct(["draft", "published"])).toBe(2); + expect(distinct(["pending", "completed", "discarded"])).toBe(3); + }); +}); + +describe("V2StatusBadge token definitions", () => { + const themes = readFileSync(resolve(__dirname, "../../../assets/css/themes.css"), "utf8"); + + // themes.css carries three independent token blocks. Defining a token in only some of them + // silently breaks the others, which is invisible in any DOM assertion. + const blocks = [ + [":root", /^:root \{$/m], + ["dark", /^\[data-theme="dark"\] \{$/m], + ["high-contrast", /^\[data-theme="high-contrast"\] \{$/m], + ] as const; + + it("finds all three theme blocks (guards this spec against a themes.css restructure)", () => { + for (const [name, pattern] of blocks) { + expect(themes, `theme block ${name} not found`).toMatch(pattern); + } + }); + + it.each([...new Set(Object.values(STATUS_TOKENS))])("defines %s in every theme block", (token) => { + // Count definitions rather than slicing blocks apart: every block that defines the token + // contributes one `--token:`, so the count must equal the number of blocks. + const definitions = themes.match(new RegExp(`^\\s*${token}\\s*:`, "gm")) ?? []; + expect(definitions.length).toBe(blocks.length); + }); +}); + +describe("V2StatusBadge label", () => { + it.each(Object.keys(STATUS_TOKENS))("translates %s rather than rendering the raw server value", (status) => { + expect(propOf(status, "text")).toBe(`t:v2Status.${status}`); }); - it("maps known statuses to distinct token colors", () => { - const color = (s: string) => mountBadge(s).findComponent({ name: "BaseBadge" }).props("color"); - expect(color("completed")).not.toBe(color("discarded")); + it("renders an unknown status raw rather than as a missing-key string", () => { + expect(propOf("something-new", "text")).toBe("something-new"); }); }); diff --git a/extralit-frontend/components/v2/common/V2StatusBadge.vue b/extralit-frontend/components/v2/common/V2StatusBadge.vue index 1ace22496..e8822426b 100644 --- a/extralit-frontend/components/v2/common/V2StatusBadge.vue +++ b/extralit-frontend/components/v2/common/V2StatusBadge.vue @@ -1,25 +1,43 @@ diff --git a/extralit-frontend/components/v2/schemas/V2RecordsTable.vue b/extralit-frontend/components/v2/schemas/V2RecordsTable.vue index 4914a7273..5a7cab345 100644 --- a/extralit-frontend/components/v2/schemas/V2RecordsTable.vue +++ b/extralit-frontend/components/v2/schemas/V2RecordsTable.vue @@ -28,7 +28,6 @@ export default { props: { records: { type: Array as PropType, required: true }, columns: { type: Array as PropType, required: true }, - workspaceId: { type: String, required: true }, }, methods: { formatCell(value: unknown): string { diff --git a/extralit-frontend/pages/schemas/[id]/index.vue b/extralit-frontend/pages/schemas/[id]/index.vue index b59efba6d..0e7b2e4c4 100644 --- a/extralit-frontend/pages/schemas/[id]/index.vue +++ b/extralit-frontend/pages/schemas/[id]/index.vue @@ -39,12 +39,7 @@

- + diff --git a/extralit-frontend/pages/schemas/[id]/settings.vue b/extralit-frontend/pages/schemas/[id]/settings.vue index 400a7e1ce..223596e6b 100644 --- a/extralit-frontend/pages/schemas/[id]/settings.vue +++ b/extralit-frontend/pages/schemas/[id]/settings.vue @@ -6,7 +6,7 @@