diff --git a/AGENTS.md b/AGENTS.md index 892553282..b68707705 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ pnpm dev # Dev server (:3001, proxies API to :8081) pnpm lint # Type check + Biome lint pnpm lint:fix # Auto-fix lint issues pnpm format # Format code +pnpm test # Run frontend tests pnpm build # Production build pnpm release # Build to server/router/frontend/dist diff --git a/web/package.json b/web/package.json index 561114057..65c139410 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,9 @@ "release": "vite build --mode release --outDir=../server/router/frontend/dist --emptyOutDir", "lint": "tsc --noEmit --skipLibCheck && biome check src", "lint:fix": "biome check --write src", - "format": "biome format --write src" + "format": "biome format --write src", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@connectrpc/connect": "^2.1.1", @@ -92,7 +94,8 @@ "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.1" }, "pnpm": { "onlyBuiltDependencies": [ diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 39a13a1f3..63a6c94b7 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -243,6 +243,9 @@ importers: vite: specifier: ^7.2.4 version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + vitest: + specifier: ^4.1.1 + version: 4.1.1(@types/node@24.10.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)) packages: @@ -1249,6 +1252,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.2.1': resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} @@ -1372,6 +1378,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1468,6 +1477,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1548,6 +1560,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.1.1': + resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==} + + '@vitest/mocker@4.1.1': + resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.1': + resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==} + + '@vitest/runner@4.1.1': + resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==} + + '@vitest/snapshot@4.1.1': + resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==} + + '@vitest/spy@4.1.1': + resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==} + + '@vitest/utils@4.1.1': + resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==} + '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} @@ -1560,6 +1601,10 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -1590,6 +1635,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1878,6 +1927,9 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1898,6 +1950,13 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -2407,6 +2466,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + package-manager-detector@1.5.0: resolution: {integrity: sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==} @@ -2665,6 +2727,9 @@ packages: resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} engines: {node: '>=6.9'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2690,6 +2755,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -2699,6 +2767,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2740,6 +2811,9 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.0.2: resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} engines: {node: '>=18'} @@ -2748,6 +2822,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} @@ -2884,6 +2962,41 @@ packages: yaml: optional: true + vitest@4.1.1: + resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.1 + '@vitest/browser-preview': 4.1.1 + '@vitest/browser-webdriverio': 4.1.1 + '@vitest/ui': 4.1.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -2911,6 +3024,11 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3842,6 +3960,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.53.3': optional: true + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 @@ -3946,6 +4066,11 @@ snapshots: dependencies: '@babel/types': 7.28.5 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -4067,6 +4192,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -4148,6 +4275,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.1.1': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1))': + dependencies: + '@vitest/spy': 4.1.1 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + + '@vitest/pretty-format@4.1.1': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.1': + dependencies: + '@vitest/utils': 4.1.1 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + '@vitest/utils': 4.1.1 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.1': {} + + '@vitest/utils@4.1.1': + dependencies: + '@vitest/pretty-format': 4.1.1 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@xobotyi/scrollbar-width@1.9.5': {} acorn@8.15.0: {} @@ -4156,6 +4324,8 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.28.4 @@ -4182,6 +4352,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -4488,6 +4660,8 @@ snapshots: dependencies: stackframe: 1.3.4 + es-module-lexer@2.0.0: {} + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -4525,6 +4699,12 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + exsolve@1.0.8: {} extend@3.0.2: {} @@ -5286,6 +5466,8 @@ snapshots: node-releases@2.0.27: {} + obug@2.1.1: {} + package-manager-detector@1.5.0: {} parent-module@1.0.1: @@ -5622,6 +5804,8 @@ snapshots: set-harmonic-interval@1.0.1: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5641,6 +5825,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-gps@3.1.2: @@ -5654,6 +5840,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + std-env@4.0.0: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5690,6 +5878,8 @@ snapshots: throttle-debounce@3.0.1: {} + tinybench@2.9.0: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -5697,6 +5887,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + toggle-selection@1.0.6: {} trim-lines@3.0.1: {} @@ -5813,6 +6005,33 @@ snapshots: lightningcss: 1.31.1 terser: 5.46.1 + vitest@4.1.1(@types/node@24.10.1)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)): + dependencies: + '@vitest/expect': 4.1.1 + '@vitest/mocker': 4.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1)) + '@vitest/pretty-format': 4.1.1 + '@vitest/runner': 4.1.1 + '@vitest/snapshot': 4.1.1 + '@vitest/spy': 4.1.1 + '@vitest/utils': 4.1.1 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@8.2.0: {} @@ -5834,6 +6053,11 @@ snapshots: web-namespaces@2.0.1: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + yallist@3.1.1: {} yaml@1.10.2: {} diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index f6b9974c6..7665b9868 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,10 +1,11 @@ import { PencilIcon, TrashIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import TableEditorDialog from "@/components/TableEditorDialog"; import { Button } from "@/components/ui/button"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { useUpdateMemo } from "@/hooks/useMemoQueries"; import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; import type { TableData } from "@/utils/markdown-table"; import { findAllTables, parseMarkdownTable, replaceNthTable } from "@/utils/markdown-table"; import { useMemoViewContext, useMemoViewDerived } from "../MemoView/MemoViewContext"; @@ -18,8 +19,8 @@ interface TableProps extends React.HTMLAttributes, ReactMarkdo children: React.ReactNode; } -export const Table = ({ children, className, node: _node, ...props }: TableProps) => { - const tableRef = useRef(null); +export const Table = ({ children, className, node, ...props }: TableProps) => { + const t = useTranslate(); const [dialogOpen, setDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableData, setTableData] = useState(null); @@ -29,32 +30,31 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps const { readonly } = useMemoViewDerived(); const { mutate: updateMemo } = useUpdateMemo(); - /** Resolve which markdown table index this rendered table corresponds to. */ - const resolveTableIndex = useCallback(() => { - const container = tableRef.current?.closest('[class*="wrap-break-word"]'); - if (!container) return -1; + /** Resolve which markdown table index this rendered table corresponds to using AST source positions. */ + const resolveTableIndex = useMemo(() => { + const nodeStart = node?.position?.start?.offset; + if (nodeStart == null) return -1; - const allTables = container.querySelectorAll("table"); - for (let i = 0; i < allTables.length; i++) { - if (tableRef.current?.contains(allTables[i])) return i; + const tables = findAllTables(memo.content); + for (let i = 0; i < tables.length; i++) { + if (nodeStart >= tables[i].start && nodeStart < tables[i].end) return i; } return -1; - }, []); + }, [memo.content, node]); const handleEditClick = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - const idx = resolveTableIndex(); const tables = findAllTables(memo.content); - if (idx < 0 || idx >= tables.length) return; + if (resolveTableIndex < 0 || resolveTableIndex >= tables.length) return; - const parsed = parseMarkdownTable(tables[idx].text); + const parsed = parseMarkdownTable(tables[resolveTableIndex].text); if (!parsed) return; setTableData(parsed); - setTableIndex(idx); + setTableIndex(resolveTableIndex); setDialogOpen(true); }, [memo.content, resolveTableIndex], @@ -65,10 +65,9 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps e.stopPropagation(); e.preventDefault(); - const idx = resolveTableIndex(); - if (idx < 0) return; + if (resolveTableIndex < 0) return; - setTableIndex(idx); + setTableIndex(resolveTableIndex); setDeleteDialogOpen(true); }, [resolveTableIndex], @@ -99,7 +98,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps return ( <> -
+
{children}
@@ -109,7 +108,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps type="button" className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-destructive/20 hover:text-destructive transition-colors" onClick={handleDeleteClick} - title="Delete table" + title={t("common.delete")} > @@ -117,7 +116,7 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps type="button" className="p-1 rounded bg-accent/80 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors" onClick={handleEditClick} - title="Edit table" + title={t("common.edit")} > @@ -131,15 +130,15 @@ export const Table = ({ children, className, node: _node, ...props }: TableProps - Delete table - Are you sure you want to delete this table? This action cannot be undone. + {t("editor.table.delete")} + {t("editor.table.delete-confirm")} - + diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index 790e7cca5..3bf4ac15b 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -15,7 +15,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounce } from "react-use"; import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata"; import { useReverseGeocoding } from "@/components/map"; -import TableEditorDialog from "@/components/TableEditorDialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -37,11 +36,10 @@ import type { LocalFile } from "../types/attachment"; const InsertMenu = (props: InsertMenuProps) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); - const { location: initialLocation, onLocationChange, onToggleFocusMode, isUploading: isUploadingProp } = props; + const { location: initialLocation, onLocationChange, onToggleFocusMode, onOpenTableEditor, isUploading: isUploadingProp } = props; const [linkDialogOpen, setLinkDialogOpen] = useState(false); const [locationDialogOpen, setLocationDialogOpen] = useState(false); - const [tableDialogOpen, setTableDialogOpen] = useState(false); const [moreSubmenuOpen, setMoreSubmenuOpen] = useState(false); const { handleTriggerEnter, handleTriggerLeave, handleContentEnter, handleContentLeave } = useDropdownMenuSubHoverDelay( @@ -98,17 +96,6 @@ const InsertMenu = (props: InsertMenuProps) => { setLinkDialogOpen(true); }, []); - const handleOpenTableDialog = useCallback(() => { - setTableDialogOpen(true); - }, []); - - const handleTableConfirm = useCallback( - (markdown: string) => { - props.onInsertText?.(markdown); - }, - [props], - ); - const handleLocationClick = useCallback(() => { setLocationDialogOpen(true); if (!initialLocation && !locationInitialized) { @@ -154,9 +141,9 @@ const InsertMenu = (props: InsertMenuProps) => { }, { key: "table", - label: "Table", + label: t("editor.table.title"), icon: TableIcon, - onClick: handleOpenTableDialog, + onClick: () => onOpenTableEditor?.(), }, { key: "link", @@ -171,7 +158,7 @@ const InsertMenu = (props: InsertMenuProps) => { onClick: handleLocationClick, }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, - [handleLocationClick, handleOpenLinkDialog, handleOpenTableDialog, handleUploadClick, t], + [handleLocationClick, handleOpenLinkDialog, onOpenTableEditor, handleUploadClick, t], ); return ( @@ -238,8 +225,6 @@ const InsertMenu = (props: InsertMenuProps) => { onCancel={handleLocationCancel} onConfirm={handleLocationConfirm} /> - - ); }; diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index 9c8781b2f..c2d5ccac2 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onInsertText }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onOpenTableEditor }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -34,7 +34,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa location={state.metadata.location} onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} - onInsertText={onInsertText} + onOpenTableEditor={onOpenTableEditor} memoName={memoName} />
diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 74c94d0f9..483e3ad6e 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -209,12 +209,7 @@ const MemoEditorImpl: React.FC = ({ {/* Metadata and toolbar grouped together at bottom */}
- editorRef.current?.insertText(text)} - /> +
diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index d93737a52..eb0b741e6 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -23,7 +23,7 @@ export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; - onInsertText?: (text: string) => void; + onOpenTableEditor?: () => void; } export interface EditorMetadataProps { @@ -46,7 +46,7 @@ export interface InsertMenuProps { location?: Location; onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; - onInsertText?: (text: string) => void; + onOpenTableEditor?: () => void; memoName?: string; } diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx index 813c265a2..f04888657 100644 --- a/web/src/components/TableEditorDialog.tsx +++ b/web/src/components/TableEditorDialog.tsx @@ -1,5 +1,6 @@ import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon, PlusIcon, TrashIcon } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslate } from "@/utils/i18n"; import type { ColumnAlignment, TableData } from "@/utils/markdown-table"; import { createEmptyTable, serializeMarkdownTable } from "@/utils/markdown-table"; import { Button } from "./ui/button"; @@ -7,12 +8,6 @@ import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } fr import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; import { VisuallyHidden } from "./ui/visually-hidden"; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const MONO_FONT = "'Fira Code', 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Consolas', ui-monospace, monospace"; - // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -31,12 +26,17 @@ type SortState = { col: number; dir: "asc" | "desc" } | null; // --------------------------------------------------------------------------- const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: TableEditorDialogProps) => { + const t = useTranslate(); const [headers, setHeaders] = useState([]); const [rows, setRows] = useState([]); + const [rowIds, setRowIds] = useState([]); const [alignments, setAlignments] = useState([]); const [sortState, setSortState] = useState(null); const inputRefs = useRef>(new Map()); + const nextRowId = useRef(0); + + const allocateRowId = useCallback(() => nextRowId.current++, []); const setInputRef = useCallback((key: string, el: HTMLInputElement | null) => { if (el) inputRefs.current.set(key, el); @@ -45,14 +45,17 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table useEffect(() => { if (open) { + nextRowId.current = 0; if (initialData) { setHeaders([...initialData.headers]); setRows(initialData.rows.map((r) => [...r])); + setRowIds(initialData.rows.map(() => nextRowId.current++)); setAlignments([...initialData.alignments]); } else { const empty = createEmptyTable(3, 2); setHeaders(empty.headers); setRows(empty.rows); + setRowIds(empty.rows.map(() => nextRowId.current++)); setAlignments(empty.alignments); } setSortState(null); @@ -105,16 +108,21 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table }; const addRow = () => { + const id = allocateRowId(); setRows((prev) => [...prev, Array.from({ length: colCount }, () => "")]); + setRowIds((prev) => [...prev, id]); }; const insertRowAt = (index: number) => { + const id = allocateRowId(); setRows((prev) => [...prev.slice(0, index), Array.from({ length: colCount }, () => ""), ...prev.slice(index)]); + setRowIds((prev) => [...prev.slice(0, index), id, ...prev.slice(index)]); }; const removeRow = (row: number) => { if (rowCount <= 1) return; setRows((prev) => prev.filter((_, i) => i !== row)); + setRowIds((prev) => prev.filter((_, i) => i !== row)); }; // ---- Sorting ---- @@ -123,18 +131,22 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table let newDir: "asc" | "desc" = "asc"; if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc"; setSortState({ col, dir: newDir }); - setRows((prev) => { - const sorted = [...prev].sort((a, b) => { - const va = (a[col] || "").toLowerCase(); - const vb = (b[col] || "").toLowerCase(); - const na = Number(va); - const nb = Number(vb); - if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na; - const cmp = va.localeCompare(vb); - return newDir === "asc" ? cmp : -cmp; - }); - return sorted; - }); + + const compareFn = (a: string[], b: string[]) => { + const va = (a[col] || "").toLowerCase(); + const vb = (b[col] || "").toLowerCase(); + const na = Number(va); + const nb = Number(vb); + if (!Number.isNaN(na) && !Number.isNaN(nb)) return newDir === "asc" ? na - nb : nb - na; + const cmp = va.localeCompare(vb); + return newDir === "asc" ? cmp : -cmp; + }; + + // Build sorted indices, then apply to both rows and rowIds. + const indices = rows.map((_, i) => i); + indices.sort((a, b) => compareFn(rows[a], rows[b])); + setRows(indices.map((i) => rows[i])); + setRowIds(indices.map((i) => rowIds[i])); }; // ---- Tab / keyboard navigation ---- @@ -190,10 +202,10 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Table Editor + {t("editor.table.editor-title")} - Edit table headers, rows, columns and sort data + {t("editor.table.editor-description")}
@@ -236,7 +248,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Insert column + {t("editor.table.insert-column")}
@@ -244,8 +256,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
setInputRef(`-1:${col}`, el)} - style={{ fontFamily: MONO_FONT }} - className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent focus:outline-none focus:ring-1 focus:ring-primary/40" + className="flex-1 min-w-0 px-2 py-1.5 font-semibold text-xs uppercase tracking-wide bg-transparent font-mono focus:outline-none focus:ring-1 focus:ring-primary/40" value={header} onChange={(e) => updateHeader(col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, -1, col)} @@ -261,7 +272,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Sort column + {t("editor.table.sort-column")} {colCount > 1 && ( @@ -274,7 +285,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Remove column + {t("editor.table.remove-column")} )}
@@ -293,7 +304,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Add column + {t("editor.table.add-column")} @@ -302,7 +313,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table {/* ============ DATA ROWS ============ */} {rows.map((row, rowIdx) => ( - + {/* Row number — with insert-row zone on top border */} @@ -325,7 +336,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Insert row + {t("editor.table.insert-row")} {rowIdx + 1} @@ -336,8 +347,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table setInputRef(`${rowIdx}:${col}`, el)} - style={{ fontFamily: MONO_FONT }} - className="w-full px-2 py-1.5 text-sm bg-transparent border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" + className="w-full px-2 py-1.5 text-sm bg-transparent font-mono border border-border focus:outline-none focus:ring-1 focus:ring-primary/40" value={cell} onChange={(e) => updateCell(rowIdx, col, e.target.value)} onKeyDown={(e) => handleKeyDown(e, rowIdx, col)} @@ -358,7 +368,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table - Remove row + {t("editor.table.remove-row")} )} @@ -373,7 +383,7 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
@@ -382,23 +392,24 @@ const TableEditorDialog = ({ open, onOpenChange, initialData, onConfirm }: Table
- {colCount} {colCount === 1 ? "column" : "columns"} · {rowCount} {rowCount === 1 ? "row" : "rows"} + {colCount} {colCount === 1 ? t("editor.table.column") : t("editor.table.columns")} · {rowCount}{" "} + {rowCount === 1 ? t("editor.table.row") : t("editor.table.rows")}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json index f19d88933..f2a6f23d5 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -124,7 +124,25 @@ "no-changes-detected": "No changes detected", "save": "Save", "saving": "Saving...", - "slash-commands": "Type `/` for commands" + "slash-commands": "Type `/` for commands", + "table": { + "title": "Table", + "editor-title": "Table Editor", + "editor-description": "Edit table headers, rows, columns and sort data", + "add-row": "Add row", + "add-column": "Add column", + "insert-column": "Insert column", + "insert-row": "Insert row", + "remove-column": "Remove column", + "remove-row": "Remove row", + "sort-column": "Sort column", + "delete": "Delete table", + "delete-confirm": "Are you sure you want to delete this table? This action cannot be undone.", + "column": "column", + "columns": "columns", + "row": "row", + "rows": "rows" + } }, "inbox": { "failed-to-load": "Failed to load inbox item", diff --git a/web/src/utils/markdown-table.test.ts b/web/src/utils/markdown-table.test.ts new file mode 100644 index 000000000..229ace880 --- /dev/null +++ b/web/src/utils/markdown-table.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; +import { + createEmptyTable, + findAllTables, + parseMarkdownTable, + replaceNthTable, + serializeMarkdownTable, + type TableData, +} from "./markdown-table"; + +// --------------------------------------------------------------------------- +// parseMarkdownTable +// --------------------------------------------------------------------------- + +describe("parseMarkdownTable", () => { + it("parses a basic table", () => { + const md = `| A | B | +| --- | --- | +| 1 | 2 | +| 3 | 4 |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + expect(result!.alignments).toEqual(["none", "none"]); + }); + + it("parses alignment markers", () => { + const md = `| Left | Center | Right | None | +| :--- | :---: | ---: | --- | +| a | b | c | d |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.alignments).toEqual(["left", "center", "right", "none"]); + }); + + it("returns null for non-table text", () => { + expect(parseMarkdownTable("hello world")).toBeNull(); + }); + + it("returns null for a single line", () => { + expect(parseMarkdownTable("| A | B |")).toBeNull(); + }); + + it("returns null when separator is invalid", () => { + const md = `| A | B | +| not | valid |`; + expect(parseMarkdownTable(md)).toBeNull(); + }); + + it("pads short rows to match header count", () => { + const md = `| A | B | C | +| --- | --- | --- | +| 1 |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.rows[0]).toEqual(["1", "", ""]); + }); + + it("trims long rows to match header count", () => { + const md = `| A | B | +| --- | --- | +| 1 | 2 | 3 | 4 |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.rows[0]).toEqual(["1", "2"]); + }); + + it("handles empty cells", () => { + const md = `| A | B | +| --- | --- | +| | |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.rows[0]).toEqual(["", ""]); + }); + + it("handles table with no data rows", () => { + const md = `| A | B | +| --- | --- |`; + const result = parseMarkdownTable(md); + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["A", "B"]); + expect(result!.rows).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// serializeMarkdownTable +// --------------------------------------------------------------------------- + +describe("serializeMarkdownTable", () => { + it("serializes a basic table", () => { + const data: TableData = { + headers: ["A", "B"], + rows: [ + ["1", "2"], + ["3", "4"], + ], + alignments: ["none", "none"], + }; + const result = serializeMarkdownTable(data); + expect(result).toContain("| A"); + expect(result).toContain("| 1"); + // Should have 4 lines: header, separator, 2 data rows + expect(result.split("\n")).toHaveLength(4); + }); + + it("preserves alignment in separator", () => { + const data: TableData = { + headers: ["Left", "Center", "Right"], + rows: [["a", "b", "c"]], + alignments: ["left", "center", "right"], + }; + const result = serializeMarkdownTable(data); + const lines = result.split("\n"); + const sep = lines[1]; + // Left alignment: starts with ":" + expect(sep).toMatch(/\| :-+\s/); + // Center alignment: starts and ends with ":" + expect(sep).toMatch(/:-+:/); + // Right alignment: ends with ":" + expect(sep).toMatch(/-+: \|$/); + }); + + it("pads cells to uniform width", () => { + const data: TableData = { + headers: ["Short", "A very long header"], + rows: [["x", "y"]], + alignments: ["none", "none"], + }; + const result = serializeMarkdownTable(data); + const lines = result.split("\n"); + // All lines should have same length due to padding + expect(new Set(lines.map((l) => l.length)).size).toBe(1); + }); + + it("round-trips through parse and serialize", () => { + const original = `| Name | Age | +| ----- | --- | +| Alice | 30 | +| Bob | 25 |`; + const parsed = parseMarkdownTable(original); + expect(parsed).not.toBeNull(); + const serialized = serializeMarkdownTable(parsed!); + const reparsed = parseMarkdownTable(serialized); + expect(reparsed).not.toBeNull(); + expect(reparsed!.headers).toEqual(parsed!.headers); + expect(reparsed!.rows).toEqual(parsed!.rows); + expect(reparsed!.alignments).toEqual(parsed!.alignments); + }); +}); + +// --------------------------------------------------------------------------- +// findAllTables +// --------------------------------------------------------------------------- + +describe("findAllTables", () => { + it("finds a single table", () => { + const content = `Some text + +| A | B | +| --- | --- | +| 1 | 2 | + +More text`; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(tables[0].text).toContain("| A | B |"); + // Verify start/end are correct by slicing + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); + + it("finds multiple tables", () => { + const content = `| A | B | +| --- | --- | +| 1 | 2 | + +Some text between + +| X | Y | +| --- | --- | +| 3 | 4 |`; + const tables = findAllTables(content); + expect(tables).toHaveLength(2); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + expect(content.slice(tables[1].start, tables[1].end)).toBe(tables[1].text); + }); + + it("returns empty for no tables", () => { + expect(findAllTables("just some text\nno tables here")).toHaveLength(0); + }); + + it("requires at least 2 lines for a table", () => { + const content = "| single line |"; + expect(findAllTables(content)).toHaveLength(0); + }); + + it("handles table at end of content without trailing newline", () => { + const content = `text +| A | B | +| --- | --- | +| 1 | 2 |`; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); + + it("handles table at start of content", () => { + const content = `| A | B | +| --- | --- | +| 1 | 2 | +more text`; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + expect(tables[0].start).toBe(0); + expect(content.slice(tables[0].start, tables[0].end)).toBe(tables[0].text); + }); +}); + +// --------------------------------------------------------------------------- +// replaceNthTable +// --------------------------------------------------------------------------- + +describe("replaceNthTable", () => { + const content = `Before + +| A | B | +| --- | --- | +| 1 | 2 | + +Middle + +| X | Y | +| --- | --- | +| 3 | 4 | + +After`; + + it("replaces the first table", () => { + const result = replaceNthTable(content, 0, "NEW TABLE"); + expect(result).toContain("NEW TABLE"); + expect(result).toContain("| X | Y |"); + expect(result).not.toContain("| A | B |"); + }); + + it("replaces the second table", () => { + const result = replaceNthTable(content, 1, "NEW TABLE"); + expect(result).toContain("| A | B |"); + expect(result).toContain("NEW TABLE"); + expect(result).not.toContain("| X | Y |"); + }); + + it("deletes a table when replacing with empty string", () => { + const result = replaceNthTable(content, 0, ""); + expect(result).not.toContain("| A | B |"); + expect(result).toContain("Before"); + expect(result).toContain("Middle"); + }); + + it("returns content unchanged for invalid index", () => { + expect(replaceNthTable(content, -1, "X")).toBe(content); + expect(replaceNthTable(content, 99, "X")).toBe(content); + }); +}); + +// --------------------------------------------------------------------------- +// createEmptyTable +// --------------------------------------------------------------------------- + +describe("createEmptyTable", () => { + it("creates table with specified dimensions", () => { + const table = createEmptyTable(3, 2); + expect(table.headers).toHaveLength(3); + expect(table.rows).toHaveLength(2); + expect(table.alignments).toHaveLength(3); + expect(table.rows[0]).toHaveLength(3); + }); + + it("creates default 2x2 table", () => { + const table = createEmptyTable(); + expect(table.headers).toHaveLength(2); + expect(table.rows).toHaveLength(2); + }); + + it("initializes with header placeholders", () => { + const table = createEmptyTable(2, 1); + expect(table.headers[0]).toBe("Header 1"); + expect(table.headers[1]).toBe("Header 2"); + }); + + it("initializes cells as empty strings", () => { + const table = createEmptyTable(2, 2); + for (const row of table.rows) { + for (const cell of row) { + expect(cell).toBe(""); + } + } + }); + + it("initializes all alignments to none", () => { + const table = createEmptyTable(3, 1); + expect(table.alignments).toEqual(["none", "none", "none"]); + }); +}); diff --git a/web/src/utils/markdown-table.ts b/web/src/utils/markdown-table.ts index 1e6368176..f2099a8bc 100644 --- a/web/src/utils/markdown-table.ts +++ b/web/src/utils/markdown-table.ts @@ -161,11 +161,10 @@ export function findAllTables(content: string): TableMatch[] { offset += lines[i].length + 1; // +1 for newline i++; } - const endOffset = offset - 1; // exclude trailing newline const text = lines.slice(startLine, i).join("\n"); // Only count if it has at least a header + separator (2 lines). if (i - startLine >= 2) { - tables.push({ text, start: startOffset, end: endOffset }); + tables.push({ text, start: startOffset, end: startOffset + text.length }); } } else { offset += lines[i].length + 1;