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 69e967cdd..dbe1fc36e 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,20 +1,166 @@ +import { PencilIcon, TrashIcon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +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 { handleError } from "@/lib/error"; 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 "@/components/MemoView/MemoViewContext"; import type { ReactMarkdownProps } from "./markdown/types"; +// --------------------------------------------------------------------------- +// Table (root wrapper with edit + delete buttons) +// --------------------------------------------------------------------------- + interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } -export const Table = ({ children, className, node: _node, ...props }: TableProps) => { +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); + const [tableIndex, setTableIndex] = useState(-1); + + const { memo } = useMemoViewContext(); + const { readonly } = useMemoViewDerived(); + const { mutateAsync: updateMemo } = useUpdateMemo(); + + const tables = useMemo(() => findAllTables(memo.content), [memo.content]); + + /** The index of the markdown table this rendered table corresponds to (from AST source positions). */ + const currentTableIndex = useMemo(() => { + const nodeStart = node?.position?.start?.offset; + if (nodeStart == null) return -1; + + for (let i = 0; i < tables.length; i++) { + if (nodeStart >= tables[i].start && nodeStart < tables[i].end) return i; + } + return -1; + }, [tables, node]); + + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (currentTableIndex < 0 || currentTableIndex >= tables.length) return; + + const parsed = parseMarkdownTable(tables[currentTableIndex].text); + if (!parsed) return; + + setTableData(parsed); + setTableIndex(currentTableIndex); + setDialogOpen(true); + }, + [tables, currentTableIndex], + ); + + const handleDeleteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (currentTableIndex < 0) return; + + setTableIndex(currentTableIndex); + setDeleteDialogOpen(true); + }, + [currentTableIndex], + ); + + const handleConfirmEdit = useCallback( + async (markdown: string) => { + if (tableIndex < 0) return; + const newContent = replaceNthTable(memo.content, tableIndex, markdown); + try { + await updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + } catch (error: unknown) { + handleError(error, toast.error, { context: "Update table", fallbackMessage: "An error occurred" }); + } + }, + [memo.content, memo.name, tableIndex, updateMemo], + ); + + const handleConfirmDelete = useCallback(async () => { + if (tableIndex < 0) return; + // Replace the table with an empty string to delete it. + const newContent = replaceNthTable(memo.content, tableIndex, ""); + try { + await updateMemo({ + update: { name: memo.name, content: newContent }, + updateMask: ["content"], + }); + setDeleteDialogOpen(false); + } catch (error: unknown) { + handleError(error, toast.error, { context: "Delete table", fallbackMessage: "An error occurred" }); + } + }, [memo.content, memo.name, tableIndex, updateMemo]); + return ( -
- - {children} -
-
+ <> +
+ + {children} +
+ {!readonly && ( +
+ + +
+ )} +
+ + + + {/* Delete confirmation dialog */} + + + + {t("editor.table.delete")} + {t("editor.table.delete-confirm")} + + + + + + + + + + ); }; +// --------------------------------------------------------------------------- +// Sub-components (unchanged) +// --------------------------------------------------------------------------- + interface TableHeadProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 50ecfb8ca..90ecc48ab 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -124,10 +124,14 @@ const MemoContent = (props: MemoContentProps) => { // Code blocks pre: CodeBlock, // Tables - table: ({ children, ...props }) => {children}
, - thead: ({ children, ...props }) => {children}, - tbody: ({ children, ...props }) => {children}, - tr: ({ children, ...props }) => {children}, + table: ({ children, node, ...props }) => ( + + {children} +
+ ), + thead: ({ children }) => {children}, + tbody: ({ children }) => {children}, + tr: ({ children }) => {children}, th: ({ children, ...props }) => {children}, td: ({ children, ...props }) => {children}, }} diff --git a/web/src/components/MemoEditor/Editor/SlashCommands.tsx b/web/src/components/MemoEditor/Editor/SlashCommands.tsx index d1ab6c12d..023ad3670 100644 --- a/web/src/components/MemoEditor/Editor/SlashCommands.tsx +++ b/web/src/components/MemoEditor/Editor/SlashCommands.tsx @@ -5,10 +5,18 @@ import { useSuggestions } from "./useSuggestions"; const SlashCommands = ({ editorRef, editorActions, commands }: SlashCommandsProps) => { const handleCommandAutocomplete = (cmd: (typeof commands)[0], word: string, index: number, actions: EditorRefActions) => { - // Remove trigger char + word, then insert command output + // Remove trigger char + word. actions.removeText(index, word.length); + + // If the command has a dialog action, invoke it instead of inserting text. + if (cmd.action) { + cmd.action(); + return; + } + + // Otherwise insert the command output text. actions.insertText(cmd.run()); - // Position cursor relative to insertion point, if specified + // Position cursor relative to insertion point, if specified. if (cmd.cursorOffset) { actions.setCursorPosition(index + cmd.cursorOffset); } diff --git a/web/src/components/MemoEditor/Editor/commands.ts b/web/src/components/MemoEditor/Editor/commands.ts index 4aa58b44a..9f8b11254 100644 --- a/web/src/components/MemoEditor/Editor/commands.ts +++ b/web/src/components/MemoEditor/Editor/commands.ts @@ -1,7 +1,10 @@ export interface Command { name: string; + /** Returns text to insert. Ignored if `action` is set. */ run: () => string; cursorOffset?: number; + /** If set, called instead of inserting run() text. Used for dialog-based commands. */ + action?: () => void; } export const editorCommands: Command[] = [ @@ -26,3 +29,18 @@ export const editorCommands: Command[] = [ cursorOffset: 1, }, ]; + +/** + * Create the full editor commands list, with the table command + * wired to open the table editor dialog instead of inserting raw markdown. + */ +export function createEditorCommands(onOpenTableEditor?: () => void): Command[] { + if (!onOpenTableEditor) return editorCommands; + + return editorCommands.map((cmd) => { + if (cmd.name === "table") { + return { ...cmd, action: onOpenTableEditor }; + } + return cmd; + }); +} diff --git a/web/src/components/MemoEditor/Editor/index.tsx b/web/src/components/MemoEditor/Editor/index.tsx index 6cce83cc9..a7dfd728f 100644 --- a/web/src/components/MemoEditor/Editor/index.tsx +++ b/web/src/components/MemoEditor/Editor/index.tsx @@ -35,6 +35,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward isInIME = false, onCompositionStart, onCompositionEnd, + commands: customCommands, } = props; const editorRef = useRef(null); @@ -210,7 +211,7 @@ const Editor = forwardRef(function Editor(props: EditorProps, ref: React.Forward onCompositionEnd={onCompositionEnd} > - + ); }); diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index a3bd05fae..3bf4ac15b 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -1,6 +1,16 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; -import { FileIcon, LinkIcon, LoaderIcon, type LucideIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; +import { + FileIcon, + LinkIcon, + LoaderIcon, + type LucideIcon, + MapPinIcon, + Maximize2Icon, + MoreHorizontalIcon, + PlusIcon, + TableIcon, +} from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useDebounce } from "react-use"; import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata"; @@ -26,7 +36,7 @@ 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); @@ -129,6 +139,12 @@ const InsertMenu = (props: InsertMenuProps) => { icon: FileIcon, onClick: handleUploadClick, }, + { + key: "table", + label: t("editor.table.title"), + icon: TableIcon, + onClick: () => onOpenTableEditor?.(), + }, { key: "link", label: t("tooltip.link-memo"), @@ -142,7 +158,7 @@ const InsertMenu = (props: InsertMenuProps) => { onClick: handleLocationClick, }, ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, - [handleLocationClick, handleOpenLinkDialog, handleUploadClick, t], + [handleLocationClick, handleOpenLinkDialog, onOpenTableEditor, handleUploadClick, t], ); return ( diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f784..41e8f2b39 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -1,11 +1,12 @@ -import { forwardRef } from "react"; +import { forwardRef, useMemo } from "react"; import Editor, { type EditorRefActions } from "../Editor"; +import { createEditorCommands } from "../Editor/commands"; import { useBlobUrls, useDragAndDrop } from "../hooks"; import { useEditorContext } from "../state"; import type { EditorContentProps } from "../types"; import type { LocalFile } from "../types/attachment"; -export const EditorContent = forwardRef(({ placeholder }, ref) => { +export const EditorContent = forwardRef(({ placeholder, onOpenTableEditor }, ref) => { const { state, actions, dispatch } = useEditorContext(); const { createBlobUrl } = useBlobUrls(); @@ -54,6 +55,9 @@ export const EditorContent = forwardRef(({ event.preventDefault(); }; + // Build commands with the table editor action wired in. + const commands = useMemo(() => createEditorCommands(onOpenTableEditor), [onOpenTableEditor]); + return (
(({ onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + commands={commands} />
); diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index ba03378ad..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 }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onOpenTableEditor }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -34,6 +34,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa location={state.metadata.location} onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} + onOpenTableEditor={onOpenTableEditor} memoName={memoName} /> diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index bfbbc8775..8151c896d 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "react-hot-toast"; +import TableEditorDialog from "@/components/TableEditorDialog"; import { useAuth } from "@/contexts/AuthContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; @@ -45,7 +46,14 @@ const MemoEditorImpl: React.FC = ({ // Get default visibility from user settings const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; - useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + useMemoInit({ + editorRef, + memo, + cacheKey, + username: currentUser?.name ?? "", + autoFocus, + defaultVisibility, + }); // Auto-save content to localStorage useAutoSave(state.content, currentUser?.name ?? "", cacheKey); @@ -57,6 +65,19 @@ const MemoEditorImpl: React.FC = ({ dispatch(actions.toggleFocusMode()); }; + // Table editor dialog (shared by slash command and toolbar). + const [tableDialogOpen, setTableDialogOpen] = useState(false); + + const handleOpenTableEditor = useCallback(() => { + setTableDialogOpen(true); + }, []); + + const handleTableConfirm = useCallback((markdown: string) => { + editorRef.current?.insertText(markdown); + setTableDialogOpen(false); + editorRef.current?.focus(); + }, []); + useKeyboard(editorRef, handleSave); async function handleSave() { @@ -70,7 +91,10 @@ const MemoEditorImpl: React.FC = ({ dispatch(actions.setLoading("saving", true)); try { - const result = await memoService.save(state, { memoName, parentMemoName }); + const result = await memoService.save(state, { + memoName, + parentMemoName, + }); if (!result.hasChanges) { toast.error(t("editor.no-changes-detected")); @@ -89,12 +113,20 @@ const MemoEditorImpl: React.FC = ({ // Ensure memo detail pages don't keep stale cached content after edits. if (memoName) { - invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.detail(memoName) })); + invalidationPromises.push( + queryClient.invalidateQueries({ + queryKey: memoKeys.detail(memoName), + }), + ); } // If this was a comment, also invalidate the comments query for the parent memo if (parentMemoName) { - invalidationPromises.push(queryClient.invalidateQueries({ queryKey: memoKeys.comments(parentMemoName) })); + invalidationPromises.push( + queryClient.invalidateQueries({ + queryKey: memoKeys.comments(parentMemoName), + }), + ); } await Promise.all(invalidationPromises); @@ -145,14 +177,16 @@ const MemoEditorImpl: React.FC = ({ )} {/* Editor content grows to fill available space in focus mode */} - + {/* Metadata and toolbar grouped together at bottom */}
- +
+ + ); }; diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index e99519d1f..eb0b741e6 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -16,12 +16,14 @@ export interface MemoEditorProps { export interface EditorContentProps { placeholder?: string; + onOpenTableEditor?: () => void; } export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; + onOpenTableEditor?: () => void; } export interface EditorMetadataProps { @@ -44,6 +46,7 @@ export interface InsertMenuProps { location?: Location; onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; + onOpenTableEditor?: () => void; memoName?: string; } @@ -68,6 +71,8 @@ export interface EditorProps { isInIME?: boolean; onCompositionStart?: () => void; onCompositionEnd?: () => void; + /** Custom commands for slash menu. If not provided, defaults are used. */ + commands?: import("../Editor/commands").Command[]; } export interface VisibilitySelectorProps { diff --git a/web/src/components/TableEditorDialog.tsx b/web/src/components/TableEditorDialog.tsx new file mode 100644 index 000000000..7c0656902 --- /dev/null +++ b/web/src/components/TableEditorDialog.tsx @@ -0,0 +1,432 @@ +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"; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; +import { VisuallyHidden } from "./ui/visually-hidden"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface TableEditorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialData?: TableData | null; + onConfirm: (markdown: string) => void; +} + +type SortState = { col: number; dir: "asc" | "desc" } | null; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +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); + else inputRefs.current.delete(key); + }, []); + + 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); + } + }, [open, initialData]); + + const colCount = headers.length; + const rowCount = rows.length; + + // ---- Cell editing ---- + + const updateHeader = (col: number, value: string) => { + setHeaders((prev) => { + const next = [...prev]; + next[col] = value; + return next; + }); + }; + + const updateCell = (row: number, col: number, value: string) => { + setRows((prev) => { + const next = prev.map((r) => [...r]); + next[row][col] = value; + return next; + }); + }; + + // ---- Add / Remove / Insert ---- + + const addColumn = () => { + setHeaders((prev) => [...prev, ""]); + setRows((prev) => prev.map((r) => [...r, ""])); + setAlignments((prev) => [...prev, "none"]); + setSortState(null); + }; + + const insertColumnAt = (index: number) => { + setHeaders((prev) => [...prev.slice(0, index), "", ...prev.slice(index)]); + setRows((prev) => prev.map((r) => [...r.slice(0, index), "", ...r.slice(index)])); + setAlignments((prev) => [...prev.slice(0, index), "none" as ColumnAlignment, ...prev.slice(index)]); + setSortState(null); + }; + + const removeColumn = (col: number) => { + if (colCount <= 1) return; + setHeaders((prev) => prev.filter((_, i) => i !== col)); + setRows((prev) => prev.map((r) => r.filter((_, i) => i !== col))); + setAlignments((prev) => prev.filter((_, i) => i !== col)); + setSortState(null); + }; + + 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 ---- + + const sortByColumn = (col: number) => { + let newDir: "asc" | "desc" = "asc"; + if (sortState && sortState.col === col && sortState.dir === "asc") newDir = "desc"; + setSortState({ col, dir: newDir }); + + const normalize = (s: string): string => + s + .trim() + .replace(/[^\d.-]/g, "") + .replace(/(?<=.)-/g, ""); + const compareFn = (a: string[], b: string[]) => { + const va = (a[col] || "").toLowerCase(); + const vb = (b[col] || "").toLowerCase(); + const ca = normalize(va); + const cb = normalize(vb); + const na = ca !== "" ? parseFloat(ca) : NaN; + const nb = cb !== "" ? parseFloat(cb) : NaN; + const aIsNum = Number.isFinite(na); + const bIsNum = Number.isFinite(nb); + if (aIsNum && bIsNum) return newDir === "asc" ? na - nb : nb - na; + if (aIsNum !== bIsNum) return aIsNum ? (newDir === "asc" ? 1 : -1) : newDir === "asc" ? -1 : 1; + 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 ---- + + const handleKeyDown = (e: React.KeyboardEvent, row: number, col: number) => { + if (e.key !== "Tab") return; + + // At grid boundaries, let the browser move focus naturally out of the grid. + if (!e.shiftKey && row === rowCount - 1 && col === colCount - 1) return; + if (e.shiftKey && row === 0 && col === 0) return; + + e.preventDefault(); + const nextCol = e.shiftKey ? col - 1 : col + 1; + let nextRow = row; + if (nextCol >= colCount) { + nextRow = row + 1; + focusCell(nextRow, 0); + } else if (nextCol < 0) { + if (row > 0) { + nextRow = row - 1; + focusCell(nextRow, colCount - 1); + } else { + focusCell(-1, colCount - 1); + } + } else { + focusCell(nextRow, nextCol); + } + }; + + const focusCell = (row: number, col: number) => { + inputRefs.current.get(`${row}:${col}`)?.focus(); + }; + + const handleConfirm = () => { + const md = serializeMarkdownTable({ headers, rows, alignments }); + onConfirm(md); + onOpenChange(false); + }; + + const SortIndicator = ({ col }: { col: number }) => { + if (sortState?.col === col) { + return sortState.dir === "asc" ? : ; + } + return ; + }; + + const totalColSpan = colCount + 2; + + return ( + + + + + + + {t("editor.table.editor-title")} + + + {t("editor.table.editor-description")} + + +
+ {/* Scrollable table area */} +
+ {/* Wrapper: w-max + overflow-x-clip so row insert line is clipped (clip avoids breaking sticky); min-w-full so table fills when narrow */} +
+ + {/* ============ STICKY HEADER ============ */} + + {/* Mask row: solid background that hides content scrolling behind the header */} + + + + {/* Header row */} + + {/* Row-number spacer */} + + ))} + + {/* Add column at end */} + + + + + {/* ============ DATA ROWS ============ */} + + {rows.map((row, rowIdx) => ( + + + {/* Row number — with insert-row zone on top border */} + + + {/* Data cells */} + {row.map((cell, col) => ( + + ))} + + {/* Row delete button */} + + + + ))} + +
+
+ + {headers.map((header, col) => ( + + {/* ---- Insert-column zone (left edge of this column) ---- */} +
insertColumnAt(col)} + > + {/* Blue vertical line through the entire table */} +
+ {/* + button — absolutely centered on the column border */} + + + + + {t("editor.table.insert-column")} + +
+ + {/* Header cell — bg covers input + sort + delete */} +
+ setInputRef(`-1:${col}`, el)} + 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)} + placeholder={`Col ${col + 1}`} + /> + + + + + {t("editor.table.sort-column")} + + {colCount > 1 && ( + + + + + {t("editor.table.remove-column")} + + )} +
+
+ + + + + {t("editor.table.add-column")} + +
+
insertRowAt(rowIdx)} + > + {/* Blue horizontal line extending across the table */} +
+ {/* + button at intersection of row border and first-column border */} + + + + + {t("editor.table.insert-row")} + +
+ {rowIdx + 1} +
+ setInputRef(`${rowIdx}:${col}`, el)} + 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)} + /> + + {rowCount > 1 && ( + + + + + {t("editor.table.remove-row")} + + )} +
+
+ + {/* Add row button below the table */} +
+ +
+
+ + {/* ============ FOOTER ============ */} +
+
+ + {colCount} {colCount === 1 ? t("editor.table.column") : t("editor.table.columns")} · {rowCount}{" "} + {rowCount === 1 ? t("editor.table.row") : t("editor.table.rows")} + + + +
+
+ + +
+
+
+
+
+ ); +}; + +export default TableEditorDialog; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 1f807758e..2a3bf4127 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..37ab970ca --- /dev/null +++ b/web/src/utils/markdown-table.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from "vitest"; +import { + createEmptyTable, + findAllTables, + parseMarkdownTable, + replaceNthTable, + serializeMarkdownTable, + type TableData, +} from "@/utils/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 a cell containing a pipe character", () => { + const data: TableData = { + headers: ["A", "B"], + rows: [["foo|bar", "baz"]], + alignments: ["none", "none"], + }; + const md = serializeMarkdownTable(data); + const parsed = parseMarkdownTable(md); + expect(parsed?.rows[0][0]).toBe("foo|bar"); + expect(parsed?.rows[0][1]).toBe("baz"); + }); + + it("round-trips a cell whose value has two backslashes before a pipe (\\\\\\\\|)", () => { + // Cell value "foo\\|bar" (two backslashes + pipe) is a valid parsed value + // that comes from markdown "foo\\\\\\|bar". The old escapeCell regex + // (? { + 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); + }); + + it("finds a pipe-less GFM table", () => { + const content = "A | B\n--- | ---\n1 | 2"; + const tables = findAllTables(content); + expect(tables).toHaveLength(1); + 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 new file mode 100644 index 000000000..4c6a55ab8 --- /dev/null +++ b/web/src/utils/markdown-table.ts @@ -0,0 +1,234 @@ +/** + * Utilities for parsing, serializing, and manipulating markdown tables. + */ +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; +import { visit } from "unist-util-visit"; + +export interface TableData { + headers: string[]; + rows: string[][]; + /** Column alignments: "left" | "center" | "right" | "none". */ + alignments: ColumnAlignment[]; +} + +export type ColumnAlignment = "left" | "center" | "right" | "none"; + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/** + * Parse a markdown table string into structured TableData. + * + * Expects a standard GFM table: + * | Header1 | Header2 | + * | ------- | ------- | + * | cell | cell | + */ +export function parseMarkdownTable(md: string): TableData | null { + const lines = md + .trim() + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0); + + if (lines.length < 2) return null; + + const parseRow = (line: string): string[] => { + // Strip leading/trailing pipes and split by pipes preceded by an even number + // of backslashes (0, 2, 4, …). A pipe preceded by an odd number of + // backslashes is an escaped pipe and must not be treated as a column + // separator. The simpler regex (?= 0 && trimmed[j] === "\\") { + backslashes++; + j--; + } + if (backslashes % 2 === 0) { + cells.push(trimmed.slice(cellStart, i).trim().replace(/\\\|/g, "|")); + cellStart = i + 1; + } + } + } + cells.push(trimmed.slice(cellStart).trim().replace(/\\\|/g, "|")); + return cells; + }; + + const headers = parseRow(lines[0]); + + // Parse the separator line for alignments. + const sepCells = parseRow(lines[1]); + const isSeparator = sepCells.every((cell) => /^:?-+:?$/.test(cell.trim())); + if (!isSeparator) return null; + + const alignments: ColumnAlignment[] = sepCells.map((cell) => { + const c = cell.trim(); + const left = c.startsWith(":"); + const right = c.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return "none"; + }); + + const rows: string[][] = []; + for (let i = 2; i < lines.length; i++) { + const cells = parseRow(lines[i]); + // Pad or trim to match header count. + while (cells.length < headers.length) cells.push(""); + if (cells.length > headers.length) cells.length = headers.length; + rows.push(cells); + } + + return { headers, rows, alignments }; +} + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +/** + * Serialize TableData into a properly-aligned markdown table string. + */ +export function serializeMarkdownTable(data: TableData): string { + const { headers, rows, alignments } = data; + const colCount = headers.length; + + const escapeCell = (text: string): string => { + let result = ""; + for (let i = 0; i < text.length; i++) { + if (text[i] === "|") { + let backslashes = 0; + let j = i - 1; + while (j >= 0 && text[j] === "\\") { + backslashes++; + j--; + } + if (backslashes % 2 === 0) result += "\\"; + } + result += text[i]; + } + return result; + }; + + // Calculate maximum width per column (minimum 3 for the separator). + const widths: number[] = []; + for (let c = 0; c < colCount; c++) { + let max = Math.max(3, escapeCell(headers[c]).length); + for (const row of rows) { + max = Math.max(max, escapeCell(row[c] || "").length); + } + widths.push(max); + } + + const padCell = (text: string, width: number, align: ColumnAlignment): string => { + const t = text || ""; + const padding = width - t.length; + if (padding <= 0) return t; + if (align === "right") return " ".repeat(padding) + t; + if (align === "center") { + const left = Math.floor(padding / 2); + const right = padding - left; + return " ".repeat(left) + t + " ".repeat(right); + } + return t + " ".repeat(padding); + }; + + const formatRow = (cells: string[]): string => { + const formatted = cells.map((cell, i) => { + const align = alignments[i] || "none"; + return padCell(escapeCell(cell), widths[i], align); + }); + return "| " + formatted.join(" | ") + " |"; + }; + + const separator = widths.map((w, i) => { + const align = alignments[i] || "none"; + const dashes = "-".repeat(w); + if (align === "center") return ":" + dashes.slice(1, -1) + ":"; + if (align === "right") return dashes.slice(0, -1) + ":"; + if (align === "left") return ":" + dashes.slice(1); + return dashes; + }); + const separatorLine = "| " + separator.join(" | ") + " |"; + + const headerLine = formatRow(headers); + const rowLines = rows.map((row) => formatRow(row)); + + return [headerLine, separatorLine, ...rowLines].join("\n"); +} + +// --------------------------------------------------------------------------- +// Find & Replace +// --------------------------------------------------------------------------- + +export interface TableMatch { + /** The raw markdown of the table. */ + text: string; + /** Start index in the source string. */ + start: number; + /** End index (exclusive) in the source string. */ + end: number; +} + +/** + * Find all markdown table blocks in a content string. + * + * Uses a GFM-aware markdown AST parser so that tables without leading/trailing + * pipes (e.g. `A | B\n--- | ---\n1 | 2`) are recognised in addition to + * fully-fenced `| … |` tables. + */ +export function findAllTables(content: string): TableMatch[] { + const tree = fromMarkdown(content, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); + + const tables: TableMatch[] = []; + visit(tree, "table", (node) => { + if (!node.position) return; + const start = node.position.start.offset ?? 0; + const end = node.position.end.offset ?? content.length; + tables.push({ text: content.slice(start, end), start, end }); + }); + return tables; +} + +/** + * Replace the nth table in the content with new markdown. + */ +export function replaceNthTable(content: string, tableIndex: number, newTableMarkdown: string): string { + const tables = findAllTables(content); + if (tableIndex < 0 || tableIndex >= tables.length) return content; + + const table = tables[tableIndex]; + return content.slice(0, table.start) + newTableMarkdown + content.slice(table.end); +} + +// --------------------------------------------------------------------------- +// Default empty table +// --------------------------------------------------------------------------- + +/** + * Create a default empty table with the given dimensions. + */ +export function createEmptyTable(cols = 2, rows = 2): TableData { + return { + headers: Array.from({ length: cols }, (_, i) => `Header ${i + 1}`), + rows: Array.from({ length: rows }, () => Array.from({ length: cols }, () => "")), + alignments: Array.from({ length: cols }, () => "none"), + }; +}