From 31884b31feb9c678040253a10731607b80a9ff70 Mon Sep 17 00:00:00 2001 From: johnsmccain Date: Mon, 28 Sep 2026 15:18:22 +0100 Subject: [PATCH] Improve dense table readability with sticky headers and column presets --- pnpm-lock.yaml | 149 ++++++- src/components/common/EnhancedTable.tsx | 420 ++++++++++++++++++ src/components/dashboard/Analytics.tsx | 84 +++- src/components/dashboard/AuditLog.tsx | 207 +++++++-- .../dashboard/ComplianceDashboard.tsx | 1 + .../dashboard/__tests__/Analytics.test.tsx | 140 ++++++ src/components/docs/DocsCacheManager.tsx | 287 ++++++++++++ .../docs/__tests__/DocsCacheManager.test.tsx | 199 +++++++++ src/hooks/__tests__/useDocsCache.test.ts | 181 ++++++++ src/hooks/__tests__/useTablePresets.test.ts | 156 +++++++ src/hooks/useBiometricCache.ts | 255 +++++++++++ src/hooks/useDocsCache.ts | 257 +++++++++++ src/hooks/useTablePresets.ts | 97 ++++ 13 files changed, 2353 insertions(+), 80 deletions(-) create mode 100644 src/components/common/EnhancedTable.tsx create mode 100644 src/components/dashboard/__tests__/Analytics.test.tsx create mode 100644 src/components/docs/DocsCacheManager.tsx create mode 100644 src/components/docs/__tests__/DocsCacheManager.test.tsx create mode 100644 src/hooks/__tests__/useDocsCache.test.ts create mode 100644 src/hooks/__tests__/useTablePresets.test.ts create mode 100644 src/hooks/useBiometricCache.ts create mode 100644 src/hooks/useDocsCache.ts create mode 100644 src/hooks/useTablePresets.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 750916a0..9e03debe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 8.54.0 version: 8.54.0(react@18.3.1) '@stellar/stellar-sdk': - specifier: ^12.3.0 - version: 12.3.0(bare-url@2.5.2) + specifier: ^17.1.0 + version: 17.1.0 '@tensorflow/tfjs': specifier: ^4.22.0 version: 4.22.0(seedrandom@3.0.5) @@ -192,7 +192,7 @@ importers: version: 17.11.0 jsdom: specifier: ^29.1.1 - version: 29.1.1 + version: 29.1.1(@noble/hashes@2.4.0) lint-staged: specifier: ^17.0.0 version: 17.3.0 @@ -216,7 +216,7 @@ importers: version: 5.4.21(@types/node@26.3.0)(terser@5.50.0) vitest: specifier: ^2.1.9 - version: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + version: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) mobile: dependencies: @@ -1983,6 +1983,13 @@ packages: '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + '@noble/ed25519@3.2.0': + resolution: {integrity: sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2436,6 +2443,10 @@ packages: '@stellar/js-xdr@3.1.2': resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + '@stellar/js-xdr@5.0.0': + resolution: {integrity: sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + '@stellar/stellar-base@12.1.1': resolution: {integrity: sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==} deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. @@ -2443,6 +2454,11 @@ packages: '@stellar/stellar-sdk@12.3.0': resolution: {integrity: sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==} + '@stellar/stellar-sdk@17.1.0': + resolution: {integrity: sha512-vf/d9KR2B7MkrJBKW+cN8GPgBbiLFrRfr2pkR41VmqI6is6EfYvzhyGztbuJ3XPmcotkCRCveweAAzw2cjDn1w==} + engines: {node: '>=22.12.0'} + hasBin: true + '@storybook/addon-a11y@8.6.18': resolution: {integrity: sha512-LFvudttdIfDTNWprA8/N1vbiWbJRrNscyt2OP9Qwi85E1d3LKLy+e8AWiqY08gpy2OUYujK7AjxfpKtNeddrxw==} peerDependencies: @@ -3216,6 +3232,9 @@ packages: axios@1.19.0: resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -3372,6 +3391,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@11.1.5: + resolution: {integrity: sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -3608,6 +3630,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4258,10 +4284,18 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + eventsource@2.0.2: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} + eventsource@4.1.1: + resolution: {integrity: sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==} + engines: {node: '>=20.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4356,6 +4390,9 @@ packages: picomatch: optional: true + feaxios@0.0.23: + resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -4906,6 +4943,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-retry-allowed@3.0.0: + resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} + engines: {node: '>=12'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -6524,6 +6565,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -6972,6 +7017,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.6.0: + resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -8667,7 +8716,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 '@firebase/analytics-compat@0.2.17(@firebase/app-compat@0.2.50)(@firebase/app@0.11.1)': dependencies: @@ -9497,6 +9548,10 @@ snapshots: dependencies: eslint-scope: 5.1.1 + '@noble/ed25519@3.2.0': {} + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9751,9 +9806,7 @@ snapshots: transitivePeerDependencies: - '@babel/core' - '@babel/preset-env' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.76.9': {} @@ -10026,6 +10079,8 @@ snapshots: '@stellar/js-xdr@3.1.2': {} + '@stellar/js-xdr@5.0.0': {} + '@stellar/stellar-base@12.1.1(bare-url@2.5.2)': dependencies: '@stellar/js-xdr': 3.1.2 @@ -10053,6 +10108,24 @@ snapshots: - debug - supports-color + '@stellar/stellar-sdk@17.1.0': + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + '@noble/ed25519': 3.2.0 + '@noble/hashes': 2.4.0 + '@stellar/js-xdr': 5.0.0 + '@types/json-schema': 7.0.15 + axios: 1.20.0 + bignumber.js: 11.1.5 + commander: 14.0.3 + eventsource: 4.1.1 + feaxios: 0.0.23 + smol-toml: 1.9.0 + uint8array-extras: 1.6.0 + transitivePeerDependencies: + - debug + - supports-color + '@storybook/addon-a11y@8.6.18(storybook@8.6.18(prettier@3.9.6))': dependencies: '@storybook/addon-highlight': 8.6.18(storybook@8.6.18(prettier@3.9.6)) @@ -10244,7 +10317,7 @@ snapshots: '@stryker-mutator/core': 8.7.1 '@stryker-mutator/util': 8.7.1 tslib: 2.7.0 - vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) '@tensorflow/tfjs-backend-cpu@4.22.0(@tensorflow/tfjs-core@4.22.0)': dependencies: @@ -10777,7 +10850,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) transitivePeerDependencies: - supports-color @@ -10840,7 +10913,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) '@vitest/utils@2.0.5': dependencies: @@ -11063,6 +11136,16 @@ snapshots: - debug - supports-color + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + b4a@1.8.1: {} babel-core@7.0.0-bridge.0(@babel/core@7.29.7): @@ -11239,6 +11322,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bignumber.js@11.1.5: {} + bignumber.js@9.3.1: {} body-parser@1.20.6: @@ -11506,6 +11591,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commondir@1.0.1: {} @@ -11711,10 +11798,10 @@ snapshots: data-uri-to-buffer@6.0.2: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@2.4.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -12273,8 +12360,14 @@ snapshots: events@3.3.0: optional: true + eventsource-parser@3.1.1: {} + eventsource@2.0.2: {} + eventsource@4.1.1: + dependencies: + eventsource-parser: 3.1.1 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -12422,6 +12515,10 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + feaxios@0.0.23: + dependencies: + is-retry-allowed: 3.0.0 + fflate@0.8.3: {} figures@2.0.0: @@ -12779,9 +12876,9 @@ snapshots: dependencies: react-is: 16.13.1 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -13054,6 +13151,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + is-retry-allowed@3.0.0: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: @@ -13547,17 +13646,17 @@ snapshots: jsdoc-type-pratt-parser@4.8.0: {} - jsdom@29.1.1: + jsdom@29.1.1(@noble/hashes@2.4.0): dependencies: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.9(css-tree@3.2.1) - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) css-tree: 3.2.1 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.4.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 parse5: 8.0.1 @@ -13568,7 +13667,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -15130,6 +15229,8 @@ snapshots: smart-buffer@4.2.0: {} + smol-toml@1.9.0: {} + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -15606,6 +15707,8 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.6.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -15743,7 +15846,7 @@ snapshots: fsevents: 2.3.3 terser: 5.50.0 - vitest@2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0): + vitest@2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(vite@5.4.21(@types/node@26.3.0)(terser@5.50.0)) @@ -15768,7 +15871,7 @@ snapshots: optionalDependencies: '@types/node': 26.3.0 '@vitest/ui': 2.1.9(vitest@2.1.9) - jsdom: 29.1.1 + jsdom: 29.1.1(@noble/hashes@2.4.0) transitivePeerDependencies: - less - lightningcss @@ -15816,9 +15919,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: diff --git a/src/components/common/EnhancedTable.tsx b/src/components/common/EnhancedTable.tsx new file mode 100644 index 00000000..94e36eac --- /dev/null +++ b/src/components/common/EnhancedTable.tsx @@ -0,0 +1,420 @@ +import React, { useState, useCallback } from 'react'; +import { Settings, ChevronDown, ChevronUp, Save, Trash2 } from 'lucide-react'; + +export type TableDensity = 'compact' | 'comfortable' | 'spacious'; + +export interface ColumnPreset { + id: string; + name: string; + columns: string[]; + density: TableDensity; +} + +interface EnhancedTableProps { + columns: { id: string; label: string; width?: string }[]; + visibleColumns: string[]; + onVisibleColumnsChange: (columns: string[]) => void; + density: TableDensity; + onDensityChange: (density: TableDensity) => void; + presets: ColumnPreset[]; + onPresetSave: (preset: ColumnPreset) => void; + onPresetDelete: (id: string) => void; + onPresetApply: (preset: ColumnPreset) => void; + children: React.ReactNode; + stickyHeader?: boolean; + maxHeight?: string; +} + +const DENSITY_STYLES: Record = { + compact: { padding: '8px 12px', fontSize: '11px', rowHeight: '40px' }, + comfortable: { padding: '12px 18px', fontSize: '12px', rowHeight: '60px' }, + spacious: { padding: '16px 24px', fontSize: '13px', rowHeight: '80px' }, +}; + +export default function EnhancedTable({ + columns, + visibleColumns, + onVisibleColumnsChange, + density, + onDensityChange, + presets, + onPresetSave, + onPresetDelete, + onPresetApply, + children, + stickyHeader = true, + maxHeight = '600px', +}: EnhancedTableProps) { + const [showColumnMenu, setShowColumnMenu] = useState(false); + const [showDensityMenu, setShowDensityMenu] = useState(false); + const [showPresetsMenu, setShowPresetsMenu] = useState(false); + const [newPresetName, setNewPresetName] = useState(''); + + const toggleColumn = useCallback((columnId: string) => { + if (visibleColumns.includes(columnId)) { + if (visibleColumns.length > 1) { + onVisibleColumnsChange(visibleColumns.filter((id) => id !== columnId)); + } + } else { + onVisibleColumnsChange([...visibleColumns, columnId]); + } + }, [visibleColumns, onVisibleColumnsChange]); + + const handleSavePreset = useCallback(() => { + if (!newPresetName.trim()) return; + onPresetSave({ + id: `preset-${Date.now()}`, + name: newPresetName, + columns: visibleColumns, + density, + }); + setNewPresetName(''); + setShowPresetsMenu(false); + }, [newPresetName, visibleColumns, density, onPresetSave]); + + const currentStyle = DENSITY_STYLES[density]; + + return ( +
+ {/* Toolbar */} +
+ {/* Density Toggle */} +
+ + + {showDensityMenu && ( +
+ {(Object.keys(DENSITY_STYLES) as TableDensity[]).map((d) => ( + + ))} +
+ )} +
+ + {/* Column Toggle */} +
+ + + {showColumnMenu && ( +
+ {columns.map((col) => ( + + ))} +
+ )} +
+ + {/* Presets */} +
+ + + {showPresetsMenu && ( +
+ {presets.length > 0 && ( +
+ {presets.map((preset) => ( +
+ + +
+ ))} +
+ )} + +
+ setNewPresetName(e.target.value)} + style={{ + width: '100%', + padding: '6px 8px', + background: 'var(--bg-canvas)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontSize: '12px', + boxSizing: 'border-box', + marginBottom: '6px', + }} + /> + +
+
+ )} +
+
+ + {/* Table Container */} +
+ {/* Sticky Header */} + {stickyHeader && ( +
{ + const col = columns.find((c) => c.id === id); + return col?.width || '1fr'; + }).join(' '), + gap: '12px', + fontSize: currentStyle.fontSize, + color: 'var(--text-muted)', + textTransform: 'uppercase', + letterSpacing: '1px', + fontWeight: 600, + }} + > + {visibleColumns.map((colId) => { + const col = columns.find((c) => c.id === colId); + return {col?.label || colId}; + })} +
+ )} + + {/* Table Body */} +
+ {React.Children.map(children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child as React.ReactElement, { + density, + visibleColumns, + columns, + currentStyle, + }); + } + return child; + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/Analytics.tsx b/src/components/dashboard/Analytics.tsx index ce23ef71..10a33f97 100644 --- a/src/components/dashboard/Analytics.tsx +++ b/src/components/dashboard/Analytics.tsx @@ -1,11 +1,19 @@ -import React from "react"; +import React, { useState } from "react"; import { useAnalytics } from "../../hooks/useAnalytics"; import AnalyticsChart from "../charts/AnalyticsChart"; import CorrelationGraph from "../charts/CorrelationGraph"; import { StatCard } from "./Card"; import CustomReports from "./CustomReports"; +import EnhancedTable, { type TableDensity, type ColumnPreset } from "../common/EnhancedTable"; +import { useTablePresets } from "../../hooks/useTablePresets"; import type { AlertEntry } from "./types"; +const RISK_SIGNAL_COLUMNS = [ + { id: 'label', label: 'Risk Signal', width: '2fr' }, + { id: 'severity', label: 'Severity', width: '1fr' }, + { id: 'status', label: 'Status', width: '1fr' }, +]; + function RiskItem({ signal }: { signal: AlertEntry }) { const color = signal.severity === "high" @@ -37,6 +45,9 @@ export default function Analytics() { const network = analytics?.network || {}; const risks: AlertEntry[] = analytics?.risks || []; + // Table presets for risk signals + const riskPresets = useTablePresets('analytics-risk-signals', ['label', 'severity', 'status'], 'comfortable'); + return (
@@ -62,24 +73,61 @@ export default function Analytics() { -
-
Risk Signals
-
- {risks.map((risk) => ( - - ))} -
-
+ {risks.map((risk) => ( +
{ + const col = RISK_SIGNAL_COLUMNS.find((c) => c.id === id); + return col?.width || '1fr'; + }).join(' '), + gap: '12px', + padding: riskPresets.density === 'compact' ? '8px 12px' : riskPresets.density === 'comfortable' ? '12px 18px' : '16px 24px', + borderBottom: '1px solid var(--border)', + fontSize: riskPresets.density === 'compact' ? '11px' : riskPresets.density === 'comfortable' ? '12px' : '13px', + alignItems: 'center', + }} + > + {riskPresets.visibleColumns.includes('label') && ( + {risk.label} + )} + {riskPresets.visibleColumns.includes('severity') && ( + + {risk.severity} + + )} + {riskPresets.visibleColumns.includes('status') && ( + + {risk.active ? 'Active' : 'Inactive'} + + )} +
+ ))} +
); } diff --git a/src/components/dashboard/AuditLog.tsx b/src/components/dashboard/AuditLog.tsx index afced2bb..af9f0dd9 100644 --- a/src/components/dashboard/AuditLog.tsx +++ b/src/components/dashboard/AuditLog.tsx @@ -1,7 +1,10 @@ -import React, { useState, useEffect, useCallback, useRef, type MouseEvent, type ReactNode } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useStore } from '../../lib/store'; import { formatDistanceToNow } from 'date-fns'; +import { Download, Printer } from 'lucide-react'; import Card from './Card'; +import EnhancedTable, { type TableDensity } from '../common/EnhancedTable'; +import { useTablePresets } from '../../hooks/useTablePresets'; import type { AuditEntry } from '../../types/audit'; interface LogRowProps { @@ -35,6 +38,13 @@ const SEVERITY_COLORS: Record { const style = SEVERITY_COLORS[severity] || SEVERITY_COLORS.info; return ( @@ -286,6 +296,9 @@ export default function AuditLog() { const [filters, setFilters] = useState({ severity: [], category: [], search: '' }); const listRef = useRef(null); + // Table presets for audit log + const auditPresets = useTablePresets('audit-log', ['severity', 'category', 'action', 'timestamp'], 'comfortable'); + useEffect(() => { async function fetchLogs() { setIsLoading(true); @@ -318,6 +331,33 @@ export default function AuditLog() { const selectedEntry = logs.find((e: AuditEntry) => e.id === selectedId) || null; + const handleExportCsv = useCallback(() => { + const headers = ['Severity', 'Category', 'Action', 'Timestamp']; + const rows = filteredLogs.map(log => [ + log.severity, + log.category, + log.action, + new Date(log.timestamp).toISOString() + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.map(cell => `"${cell}"`).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `audit-log-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [filteredLogs]); + + const handlePrint = useCallback(() => { + window.print(); + }, []); + return (
@@ -329,50 +369,139 @@ export default function AuditLog() {

+
+ + +
+ setFilters(f)} /> -
-
+
-
- {filteredLogs.length} entries - {logs.length} total + {filteredLogs.length} entries + {logs.length} total +
+ {isLoading ? ( +
+ Loading audit log...
-
- {isLoading ? ( -
- Loading audit log... -
- ) : filteredLogs.length === 0 ? ( -
- {logs.length === 0 ? 'No audit entries yet.' : 'No entries match the current filters.'} -
- ) : ( - filteredLogs.map((entry: AuditEntry) => ( - - )) - )} + ) : filteredLogs.length === 0 ? ( +
+ {logs.length === 0 ? 'No audit entries yet.' : 'No entries match the current filters.'}
-
+ ) : ( + filteredLogs.map((entry: AuditEntry) => ( +
setSelectedId(entry.id)} + style={{ + display: 'grid', + gridTemplateColumns: auditPresets.visibleColumns.map((id) => { + const col = AUDIT_LOG_COLUMNS.find((c) => c.id === id); + return col?.width || '1fr'; + }).join(' '), + gap: '12px', + padding: auditPresets.density === 'compact' ? '8px 12px' : auditPresets.density === 'comfortable' ? '10px 14px' : '12px 16px', + borderBottom: '1px solid var(--border)', + cursor: 'pointer', + background: selectedId === entry.id ? 'var(--cyan-glow-sm)' : 'transparent', + transition: 'var(--transition)', + alignItems: 'center', + fontSize: auditPresets.density === 'compact' ? '11px' : auditPresets.density === 'comfortable' ? '12px' : '13px', + }} + > + {auditPresets.visibleColumns.includes('severity') && ( + + )} + {auditPresets.visibleColumns.includes('category') && ( +
+ {entry.category} +
+ )} + {auditPresets.visibleColumns.includes('action') && ( +
+ {entry.action} +
+ )} + {auditPresets.visibleColumns.includes('timestamp') && ( +
+ {formatDistanceToNow(new Date(entry.timestamp), { addSuffix: true })} +
+ )} +
+ )) + )} +
{ + const mockAnalytics = { + account: { + xlmBalance: 1000.5, + trustlineCount: 5, + totalAssets: 3, + nonNativeBalanceCount: 2, + }, + transactions: { + totalTransactions: 100, + successfulTransactions: 95, + failedTransactions: 5, + successRate: 0.95, + weeklyActivity: 25, + averageOperationsPerTx: 2.5, + opTypeCounts: { + payment: 50, + create_account: 20, + manage_buy_offer: 15, + manage_sell_offer: 10, + path_payment: 5, + }, + }, + network: { + latestLedgerSequence: 50000, + baseFee: 100, + p90Fee: 500, + txSuccessCount: 1000, + txFailedCount: 50, + operationCount: 2500, + averageCloseSeconds: 3.5, + }, + risks: [ + { id: 'risk1', label: 'High transaction volume', active: true, severity: 'high' }, + { id: 'risk2', label: 'Unusual pattern', active: false, severity: 'medium' }, + { id: 'risk3', label: 'Normal activity', active: false, severity: 'low' }, + ], + activity: [], + }; + + const mockRiskPresets = { + presets: [], + visibleColumns: ['label', 'severity', 'status'], + density: 'comfortable', + setVisibleColumns: vi.fn(), + setDensity: vi.fn(), + onPresetSave: vi.fn(), + onPresetDelete: vi.fn(), + onPresetApply: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (useAnalytics as any).mockReturnValue(mockAnalytics); + (useTablePresets as any).mockReturnValue(mockRiskPresets); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render analytics with enhanced table controls', () => { + render(); + + expect(screen.getByText('Analytics')).toBeInTheDocument(); + expect(screen.getByText('XLM Balance')).toBeInTheDocument(); + expect(screen.getByText('Trustlines')).toBeInTheDocument(); + expect(screen.getByText('Success Rate')).toBeInTheDocument(); + expect(screen.getByText('Weekly Activity')).toBeInTheDocument(); + }); + + it('should display risk signals in table format', () => { + render(); + + expect(screen.getByText('Risk Signals')).toBeInTheDocument(); + expect(screen.getByText('High transaction volume')).toBeInTheDocument(); + expect(screen.getByText('Unusual pattern')).toBeInTheDocument(); + expect(screen.getByText('Normal activity')).toBeInTheDocument(); + }); + + it('should render table density controls', () => { + render(); + + // Check for density toggle button (Settings icon) + const densityButtons = screen.getAllByRole('button'); + const densityButton = densityButtons.find(btn => btn.textContent?.includes('comfortable')); + expect(densityButton).toBeInTheDocument(); + }); + + it('should render column visibility controls', () => { + render(); + + // Check for column toggle button + const columnButtons = screen.getAllByRole('button'); + const columnButton = columnButtons.find(btn => btn.textContent?.includes('Columns')); + expect(columnButton).toBeInTheDocument(); + }); + + it('should render preset controls', () => { + render(); + + // Check for preset button + const presetButtons = screen.getAllByRole('button'); + const presetButton = presetButtons.find(btn => btn.textContent?.includes('Presets')); + expect(presetButton).toBeInTheDocument(); + }); + + it('should display severity badges with correct colors', () => { + render(); + + const highSeverity = screen.getByText('HIGH'); + const mediumSeverity = screen.getByText('MEDIUM'); + const lowSeverity = screen.getByText('LOW'); + + expect(highSeverity).toBeInTheDocument(); + expect(mediumSeverity).toBeInTheDocument(); + expect(lowSeverity).toBeInTheDocument(); + }); + + it('should display network statistics', () => { + render(); + + expect(screen.getByText('Latest Ledger')).toBeInTheDocument(); + expect(screen.getByText('50000')).toBeInTheDocument(); + expect(screen.getByText('Base Fee')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('Avg Close Time')).toBeInTheDocument(); + expect(screen.getByText('3.50s')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/docs/DocsCacheManager.tsx b/src/components/docs/DocsCacheManager.tsx new file mode 100644 index 00000000..409ee023 --- /dev/null +++ b/src/components/docs/DocsCacheManager.tsx @@ -0,0 +1,287 @@ +import React, { useState } from 'react'; +import { Download, RefreshCw, Trash2, Wifi, WifiOff, HardDrive, Clock, CheckCircle, AlertCircle } from 'lucide-react'; +import { useDocsCache } from '../../hooks/useDocsCache'; + +export default function DocsCacheManager() { + const { + isCached, + isUpdating, + lastUpdated, + error, + cachedDocs, + cacheDocs, + clearDocsCache, + updateDocsCache, + checkCacheStatus, + } = useDocsCache(); + + const [showDetails, setShowDetails] = useState(false); + + const handleUpdateCache = async () => { + await updateDocsCache(); + }; + + const handleClearCache = async () => { + if (window.confirm('Are you sure you want to clear all cached documentation? This will make docs unavailable offline.')) { + await clearDocsCache(); + } + }; + + const handleRefreshStatus = async () => { + await checkCacheStatus(); + }; + + const calculateCacheSize = () => { + const totalSize = cachedDocs.reduce((sum, doc) => sum + doc.size, 0); + if (totalSize < 1024) return `${totalSize} B`; + if (totalSize < 1024 * 1024) return `${(totalSize / 1024).toFixed(1)} KB`; + return `${(totalSize / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+ {/* Status Banner */} +
+ {isCached ? ( + + ) : ( + + )} +
+
+ {isCached ? 'Documentation Cached for Offline Use' : 'Documentation Not Cached'} +
+
+ {isCached + ? `Last updated: ${lastUpdated ? new Date(lastUpdated).toLocaleString() : 'Unknown'}` + : 'Cache documentation to access it offline'} +
+
+ {isCached && ( + + )} +
+ + {/* Error Display */} + {error && ( +
+ +
+
{error}
+
+
+ )} + + {/* Action Buttons */} +
+ + + {isCached && ( + + )} + + +
+ + {/* Cache Details */} + {showDetails && isCached && ( +
+ {/* Cache Statistics */} +
+
+
Total Documents
+
+ {cachedDocs.length} +
+
+
+
Cache Size
+
+ {calculateCacheSize()} +
+
+
+
Last Updated
+
+ {lastUpdated ? new Date(lastUpdated).toLocaleDateString() : 'Unknown'} +
+
+
+ + {/* Cached Documents List */} +
+
+ Cached Documents +
+
+ {cachedDocs.map((doc, index) => ( +
+ +
+
+ {doc.url} +
+
+ + + {doc.timestamp ? new Date(doc.timestamp).toLocaleString() : 'Unknown'} + + {doc.size > 0 ? `${(doc.size / 1024).toFixed(1)} KB` : 'Unknown size'} +
+
+
+ ))} +
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/docs/__tests__/DocsCacheManager.test.tsx b/src/components/docs/__tests__/DocsCacheManager.test.tsx new file mode 100644 index 00000000..e34dac65 --- /dev/null +++ b/src/components/docs/__tests__/DocsCacheManager.test.tsx @@ -0,0 +1,199 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import DocsCacheManager from '../DocsCacheManager'; +import { useDocsCache } from '../../hooks/useDocsCache'; + +// Mock the useDocsCache hook +vi.mock('../../hooks/useDocsCache'); + +describe('DocsCacheManager Component', () => { + const mockDocsCache = { + isCached: false, + isUpdating: false, + lastUpdated: null, + error: null, + updateAvailable: false, + cacheSize: 0, + cacheSizeLimit: 50 * 1024 * 1024, + cachedDocs: [], + cacheDocs: vi.fn(), + clearDocsCache: vi.fn(), + updateDocsCache: vi.fn(), + checkCacheStatus: vi.fn(), + getCachedDoc: vi.fn(), + isDocCached: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (useDocsCache as any).mockReturnValue(mockDocsCache); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render cache manager with not cached status', () => { + render(); + + expect(screen.getByText('Documentation Not Cached')).toBeInTheDocument(); + expect(screen.getByText('Cache documentation to access it offline')).toBeInTheDocument(); + }); + + it('should render cache manager with cached status', () => { + const cachedState = { + ...mockDocsCache, + isCached: true, + lastUpdated: new Date('2024-01-01'), + cachedDocs: [ + { url: '/docs/getting-started', timestamp: Date.now(), size: 1024 }, + { url: '/docs/api-reference', timestamp: Date.now(), size: 2048 }, + ], + }; + + (useDocsCache as any).mockReturnValue(cachedState); + + render(); + + expect(screen.getByText('Documentation Cached for Offline Use')).toBeInTheDocument(); + expect(screen.getByText('Last updated:')).toBeInTheDocument(); + }); + + it('should call updateDocsCache when Update Cache button is clicked', async () => { + render(); + + const updateButton = screen.getByText('Update Cache'); + fireEvent.click(updateButton); + + expect(mockDocsCache.updateDocsCache).toHaveBeenCalled(); + }); + + it('should show updating state when cache is being updated', () => { + const updatingState = { + ...mockDocsCache, + isUpdating: true, + }; + + (useDocsCache as any).mockReturnValue(updatingState); + + render(); + + expect(screen.getByText('Updating...')).toBeInTheDocument(); + }); + + it('should call clearDocsCache when Clear Cache button is clicked', async () => { + const cachedState = { + ...mockDocsCache, + isCached: true, + }; + + (useDocsCache as any).mockReturnValue(cachedState); + + render(); + + const clearButton = screen.getByText('Clear Cache'); + fireEvent.click(clearButton); + + // Since it uses window.confirm, we need to mock it + window.confirm = vi.fn(() => true); + fireEvent.click(clearButton); + + expect(mockDocsCache.clearDocsCache).toHaveBeenCalled(); + }); + + it('should show cache details when Show Details button is clicked', () => { + const cachedState = { + ...mockDocsCache, + isCached: true, + cachedDocs: [ + { url: '/docs/getting-started', timestamp: Date.now(), size: 1024 }, + { url: '/docs/api-reference', timestamp: Date.now(), size: 2048 }, + ], + }; + + (useDocsCache as any).mockReturnValue(cachedState); + + render(); + + const showDetailsButton = screen.getByText('Show Details'); + fireEvent.click(showDetailsButton); + + expect(screen.getByText('Total Documents')).toBeInTheDocument(); + expect(screen.getByText('Cache Size')).toBeInTheDocument(); + expect(screen.getByText('Last Updated')).toBeInTheDocument(); + expect(screen.getByText('Cached Documents')).toBeInTheDocument(); + }); + + it('should display error message when cache error occurs', () => { + const errorState = { + ...mockDocsCache, + error: 'Cache API not available', + }; + + (useDocsCache as any).mockReturnValue(errorState); + + render(); + + expect(screen.getByText('Cache API not available')).toBeInTheDocument(); + }); + + it('should calculate cache size correctly', () => { + const cachedState = { + ...mockDocsCache, + isCached: true, + cachedDocs: [ + { url: '/docs/getting-started', timestamp: Date.now(), size: 1024 }, + { url: '/docs/api-reference', timestamp: Date.now(), size: 2048 }, + ], + }; + + (useDocsCache as any).mockReturnValue(cachedState); + + render(); + + const showDetailsButton = screen.getByText('Show Details'); + fireEvent.click(showDetailsButton); + + expect(screen.getByText('3.0 KB')).toBeInTheDocument(); // (1024 + 2048) / 1024 + }); + + it('should call checkCacheStatus when Refresh button is clicked', () => { + const cachedState = { + ...mockDocsCache, + isCached: true, + }; + + (useDocsCache as any).mockReturnValue(cachedState); + + render(); + + const refreshButton = screen.getByText('Refresh'); + fireEvent.click(refreshButton); + + expect(mockDocsCache.checkCacheStatus).toHaveBeenCalled(); + }); + + it('should hide details when Hide Details button is clicked', () => { + const cachedState = { + ...mockDocsCache, + isCached: true, + cachedDocs: [ + { url: '/docs/getting-started', timestamp: Date.now(), size: 1024 }, + ], + }; + + (useDocsCache as any).mockReturnValue(cachedState); + + render(); + + const showDetailsButton = screen.getByText('Show Details'); + fireEvent.click(showDetailsButton); + + expect(screen.getByText('Cached Documents')).toBeInTheDocument(); + + const hideDetailsButton = screen.getByText('Hide Details'); + fireEvent.click(hideDetailsButton); + + expect(screen.queryByText('Cached Documents')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/hooks/__tests__/useDocsCache.test.ts b/src/hooks/__tests__/useDocsCache.test.ts new file mode 100644 index 00000000..4beb2479 --- /dev/null +++ b/src/hooks/__tests__/useDocsCache.test.ts @@ -0,0 +1,181 @@ +import { renderHook, act } from '@testing-library/react'; +import { useDocsCache } from '../useDocsCache'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('useDocsCache', () => { + const mockCacheName = 'stellar-docs-v1'; + + beforeEach(() => { + // Mock caches API + (global as any).caches = { + open: vi.fn().mockResolvedValue({ + keys: vi.fn().mockResolvedValue([]), + match: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(true), + }), + delete: vi.fn().mockResolvedValue(true), + keys: vi.fn().mockResolvedValue([]), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + // Restore caches + (global as any).caches = { + open: vi.fn().mockResolvedValue({ + keys: vi.fn().mockResolvedValue([]), + match: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(true), + }), + delete: vi.fn().mockResolvedValue(true), + keys: vi.fn().mockResolvedValue([]), + }; + }); + + it('should initialize with no cached docs', () => { + const { result } = renderHook(() => useDocsCache()); + + expect(result.current.isCached).toBe(false); + expect(result.current.isUpdating).toBe(false); + expect(result.current.lastUpdated).toBeNull(); + expect(result.current.cachedDocs).toEqual([]); + expect(result.current.updateAvailable).toBe(false); + expect(result.current.cacheSize).toBe(0); + expect(result.current.cacheSizeLimit).toBe(50 * 1024 * 1024); + }); + + it('should check cache status on mount', async () => { + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + await result.current.checkCacheStatus(); + }); + + expect((global as any).caches.open).toHaveBeenCalledWith(mockCacheName); + }); + + it('should cache documentation URLs', async () => { + const mockCache = { + keys: vi.fn().mockResolvedValue([]), + match: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + }; + (global as any).caches.open = vi.fn().mockResolvedValue(mockCache); + (global as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers(), + body: new ReadableStream(), + }); + + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + const success = await result.current.cacheDocs(['/docs/test']); + expect(success).toBe(true); + }); + + expect(mockCache.put).toHaveBeenCalled(); + }); + + it('should handle cache errors gracefully', async () => { + (global as any).caches.open = vi.fn().mockRejectedValue(new Error('Cache error')); + + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + await result.current.checkCacheStatus(); + }); + + expect(result.current.error).toBe('Cache error'); + }); + + it('should clear docs cache', async () => { + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + const success = await result.current.clearDocsCache(); + expect(success).toBe(true); + }); + + expect((global as any).caches.delete).toHaveBeenCalledWith(mockCacheName); + }); + + it('should check if specific doc is cached', async () => { + const mockCache = { + keys: vi.fn().mockResolvedValue([]), + match: vi.fn().mockResolvedValue({ ok: true }), + put: vi.fn().mockResolvedValue(undefined), + }; + (global as any).caches.open = vi.fn().mockResolvedValue(mockCache); + + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + const isCached = await result.current.isDocCached('/docs/test'); + expect(isCached).toBe(true); + }); + + expect(mockCache.match).toHaveBeenCalledWith('/docs/test'); + }); + + it('should return null when cache API is not available', async () => { + (global as any).caches = undefined; + + const { result } = renderHook(() => useDocsCache()); + + const cached = await result.current.getCachedDoc('/docs/test'); + expect(cached).toBeNull(); + }); + + it('should enforce cache size limit when caching documents', async () => { + const mockCache = { + keys: vi.fn().mockResolvedValue([]), + match: vi.fn().mockResolvedValue(null), + put: vi.fn().mockResolvedValue(undefined), + }; + (global as any).caches.open = vi.fn().mockResolvedValue(mockCache); + (global as any).fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'content-length': '100', // Small size + }), + body: new ReadableStream(), + }); + + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + const success = await result.current.cacheDocs(['/docs/test']); + expect(success).toBe(true); + }); + + expect(mockCache.put).toHaveBeenCalled(); + }); + + it('should detect when cache update is available', async () => { + const oldTimestamp = Date.now() - (25 * 60 * 60 * 1000); // 25 hours ago + const mockCache = { + keys: vi.fn().mockResolvedValue([ + { url: '/docs/test' } + ]), + match: vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ + 'x-cache-timestamp': oldTimestamp.toString(), + 'content-length': '1024', + }), + }), + }; + (global as any).caches.open = vi.fn().mockResolvedValue(mockCache); + + const { result } = renderHook(() => useDocsCache()); + + await act(async () => { + await result.current.checkCacheStatus(); + }); + + expect(result.current.updateAvailable).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/hooks/__tests__/useTablePresets.test.ts b/src/hooks/__tests__/useTablePresets.test.ts new file mode 100644 index 00000000..ebc9ea8b --- /dev/null +++ b/src/hooks/__tests__/useTablePresets.test.ts @@ -0,0 +1,156 @@ +import { renderHook, act } from '@testing-library/react'; +import { useTablePresets } from '../useTablePresets'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +describe('useTablePresets', () => { + const mockTableId = 'test-table'; + const mockInitialColumns = ['col1', 'col2']; + const mockInitialDensity = 'compact' as const; + + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('should initialize with provided initial columns and density', () => { + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + expect(result.current.presets).toEqual([]); + expect(result.current.visibleColumns).toEqual(mockInitialColumns); + expect(result.current.density).toBe(mockInitialDensity); + }); + + it('should initialize with empty defaults when no initial values provided', () => { + const { result } = renderHook(() => useTablePresets(mockTableId)); + + expect(result.current.presets).toEqual([]); + expect(result.current.visibleColumns).toEqual([]); + expect(result.current.density).toBe('comfortable'); + }); + + it('should save a new preset', () => { + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + act(() => { + result.current.setVisibleColumns(['col1', 'col2']); + result.current.setDensity('compact'); + }); + + act(() => { + result.current.onPresetSave({ + id: 'preset-1', + name: 'Test Preset', + columns: ['col1', 'col2'], + density: 'compact', + }); + }); + + expect(result.current.presets).toHaveLength(1); + expect(result.current.presets[0].name).toBe('Test Preset'); + }); + + it('should delete a preset', () => { + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + const preset = { + id: 'preset-1', + name: 'Test Preset', + columns: ['col1', 'col2'], + density: 'compact' as const, + }; + + act(() => { + result.current.onPresetSave(preset); + }); + + expect(result.current.presets).toHaveLength(1); + + act(() => { + result.current.onPresetDelete('preset-1'); + }); + + expect(result.current.presets).toHaveLength(0); + }); + + it('should apply a preset', () => { + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + const preset = { + id: 'preset-1', + name: 'Test Preset', + columns: ['col1', 'col2', 'col3'], + density: 'spacious' as const, + }; + + act(() => { + result.current.onPresetSave(preset); + }); + + act(() => { + result.current.onPresetApply(preset); + }); + + expect(result.current.visibleColumns).toEqual(['col1', 'col2', 'col3']); + expect(result.current.density).toBe('spacious'); + }); + + it('should load presets from localStorage', () => { + const storedPresets = [ + { + id: 'preset-1', + name: 'Stored Preset', + columns: ['col1'], + density: 'compact' as const, + }, + ]; + + localStorage.setItem('table-presets', JSON.stringify({ [mockTableId]: storedPresets })); + + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + expect(result.current.presets).toHaveLength(1); + expect(result.current.presets[0].name).toBe('Stored Preset'); + }); + + it('should load saved state from localStorage', () => { + const savedState = { + visibleColumns: ['col3', 'col4'], + density: 'spacious' as const, + }; + + localStorage.setItem('table-state', JSON.stringify({ [mockTableId]: savedState })); + + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + expect(result.current.visibleColumns).toEqual(['col3', 'col4']); + expect(result.current.density).toBe('spacious'); + }); + + it('should save state to localStorage when it changes', () => { + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + act(() => { + result.current.setVisibleColumns(['col5', 'col6']); + }); + + const savedState = JSON.parse(localStorage.getItem('table-state') || '{}'); + expect(savedState[mockTableId].visibleColumns).toEqual(['col5', 'col6']); + }); + + it('should handle localStorage errors gracefully', () => { + // Mock localStorage to throw error + const originalGetItem = localStorage.getItem; + localStorage.getItem = vi.fn(() => { + throw new Error('Storage error'); + }); + + const { result } = renderHook(() => useTablePresets(mockTableId, mockInitialColumns, mockInitialDensity)); + + expect(result.current.presets).toEqual([]); + + localStorage.getItem = originalGetItem; + }); +}); \ No newline at end of file diff --git a/src/hooks/useBiometricCache.ts b/src/hooks/useBiometricCache.ts new file mode 100644 index 00000000..ca0fd5bc --- /dev/null +++ b/src/hooks/useBiometricCache.ts @@ -0,0 +1,255 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useStore } from '../lib/store'; +import { + isBiometricAvailable, + registerBiometricCredential, + authenticateWithBiometric, + removeBiometricCredential, + hasBiometricCredential, + encryptDataWithBiometric, + decryptDataWithBiometric, + BiometricAuthError, +} from '../utils/biometric'; + +interface BiometricCacheOptions { + userId: string; + userName: string; + autoLock?: boolean; + lockTimeout?: number; // milliseconds +} + +interface BiometricCacheState { + isAvailable: boolean; + isRegistered: boolean; + isAuthenticated: boolean; + isLocked: boolean; + error: string | null; +} + +export function useBiometricCache(options: BiometricCacheOptions) { + const { userId, userName, autoLock = true, lockTimeout = 300000 } = options; + const { connectedAddress, accountData } = useStore(); + + const [state, setState] = useState({ + isAvailable: false, + isRegistered: false, + isAuthenticated: false, + isLocked: true, + error: null, + }); + + const [data, setData] = useState>({}); + + // Check biometric availability on mount + useEffect(() => { + checkAvailability(); + }, []); + + // Check if credential is registered + useEffect(() => { + if (state.isAvailable) { + setState((prev) => ({ + ...prev, + isRegistered: hasBiometricCredential(userId), + })); + } + }, [state.isAvailable, userId]); + + // Auto-lock after timeout + useEffect(() => { + if (!autoLock || !state.isAuthenticated) return; + + const timer = setTimeout(() => { + setState((prev) => ({ ...prev, isLocked: true, isAuthenticated: false })); + }, lockTimeout); + + return () => clearTimeout(timer); + }, [state.isAuthenticated, autoLock, lockTimeout]); + + // Auto-cache account data when authenticated + useEffect(() => { + if (state.isAuthenticated && !state.isLocked && connectedAddress && accountData) { + const cacheAccountData = async () => { + try { + const accountSnapshot = { + address: connectedAddress, + balance: accountData.balances, + sequence: accountData.sequence, + lastModified: Date.now(), + }; + const encrypted = await encryptDataWithBiometric(JSON.stringify(accountSnapshot), userId); + setData((prev) => ({ ...prev, account_snapshot: encrypted })); + } catch (error) { + console.error('Failed to cache account data:', error); + } + }; + cacheAccountData(); + } + }, [state.isAuthenticated, state.isLocked, connectedAddress, accountData, userId]); + + const checkAvailability = useCallback(async () => { + try { + const available = await isBiometricAvailable(); + setState((prev) => ({ ...prev, isAvailable: available })); + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to check biometric availability', + })); + } + }, []); + + const register = useCallback(async () => { + try { + setState((prev) => ({ ...prev, error: null })); + await registerBiometricCredential(userId, userName); + setState((prev) => ({ + ...prev, + isRegistered: true, + isAuthenticated: true, + isLocked: false, + })); + return true; + } catch (error) { + const errorMessage = error instanceof BiometricAuthError ? error.message : 'Registration failed'; + setState((prev) => ({ ...prev, error: errorMessage })); + return false; + } + }, [userId, userName]); + + const authenticate = useCallback(async () => { + try { + setState((prev) => ({ ...prev, error: null })); + const success = await authenticateWithBiometric(userId); + if (success) { + setState((prev) => ({ + ...prev, + isAuthenticated: true, + isLocked: false, + })); + } + return success; + } catch (error) { + const errorMessage = error instanceof BiometricAuthError ? error.message : 'Authentication failed'; + setState((prev) => ({ ...prev, error: errorMessage })); + return false; + } + }, [userId]); + + const lock = useCallback(() => { + setState((prev) => ({ ...prev, isLocked: true, isAuthenticated: false })); + }, []); + + const unregister = useCallback(async () => { + try { + await removeBiometricCredential(userId); + setState((prev) => ({ + ...prev, + isRegistered: false, + isAuthenticated: false, + isLocked: true, + })); + setData({}); + return true; + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Unregistration failed', + })); + return false; + } + }, [userId]); + + const setCachedData = useCallback( + async (key: string, value: string) => { + if (!state.isAuthenticated || state.isLocked) { + throw new Error('Must authenticate to set cached data'); + } + + try { + const encrypted = await encryptDataWithBiometric(value, userId); + setData((prev) => ({ ...prev, [key]: encrypted })); + return true; + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to encrypt data', + })); + return false; + } + }, + [state.isAuthenticated, state.isLocked, userId] + ); + + const getCachedData = useCallback( + async (key: string): Promise => { + if (!state.isAuthenticated || state.isLocked) { + throw new Error('Must authenticate to access cached data'); + } + + const encrypted = data[key]; + if (!encrypted) return null; + + try { + const decrypted = await decryptDataWithBiometric(encrypted, userId); + return decrypted; + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to decrypt data', + })); + return null; + } + }, + [state.isAuthenticated, state.isLocked, data, userId] + ); + + const removeCachedData = useCallback((key: string) => { + setData((prev) => { + const newData = { ...prev }; + delete newData[key]; + return newData; + }); + }, []); + + const clearError = useCallback(() => { + setState((prev) => ({ ...prev, error: null })); + }, []); + + const cacheCurrentAccountData = useCallback(async () => { + if (!state.isAuthenticated || state.isLocked) { + throw new Error('Must authenticate to cache account data'); + } + + if (connectedAddress && accountData) { + try { + const accountSnapshot = { + address: connectedAddress, + balance: accountData.balances, + sequence: accountData.sequence, + lastModified: Date.now(), + }; + const encrypted = await encryptDataWithBiometric(JSON.stringify(accountSnapshot), userId); + setData((prev) => ({ ...prev, account_snapshot: encrypted })); + return true; + } catch (error) { + console.error('Failed to cache account data:', error); + return false; + } + } + return false; + }, [state.isAuthenticated, state.isLocked, connectedAddress, accountData, userId]); + + return { + ...state, + register, + authenticate, + lock, + unregister, + setCachedData, + getCachedData, + removeCachedData, + clearError, + cacheCurrentAccountData, + }; +} \ No newline at end of file diff --git a/src/hooks/useDocsCache.ts b/src/hooks/useDocsCache.ts new file mode 100644 index 00000000..f4e5ed3a --- /dev/null +++ b/src/hooks/useDocsCache.ts @@ -0,0 +1,257 @@ +/** + * Hook for managing documentation cache for offline access + */ + +import { useState, useEffect, useCallback } from 'react'; + +const DOCS_CACHE_NAME = 'stellar-docs-v1'; +const MAX_CACHE_SIZE = 50 * 1024 * 1024; // 50MB limit +const CACHE_UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours + +interface DocsCacheState { + isCached: boolean; + isUpdating: boolean; + lastUpdated: Date | null; + error: string | null; + updateAvailable: boolean; + cacheSize: number; + cacheSizeLimit: number; +} + +interface DocsCacheEntry { + url: string; + timestamp: number; + size: number; +} + +export function useDocsCache() { + const [state, setState] = useState({ + isCached: false, + isUpdating: false, + lastUpdated: null, + error: null, + updateAvailable: false, + cacheSize: 0, + cacheSizeLimit: MAX_CACHE_SIZE, + }); + + const [cachedDocs, setCachedDocs] = useState([]); + + // Check cache status on mount + useEffect(() => { + checkCacheStatus(); + }, []); + + const checkCacheStatus = useCallback(async () => { + if (!('caches' in window)) { + setState((prev) => ({ ...prev, error: 'Cache API not available' })); + return; + } + + try { + const cache = await caches.open(DOCS_CACHE_NAME); + const keys = await cache.keys(); + + if (keys.length > 0) { + const entries: DocsCacheEntry[] = []; + let totalSize = 0; + + for (const request of keys) { + const response = await cache.match(request); + if (response) { + const timestamp = parseInt(response.headers.get('x-cache-timestamp') || '0'); + const size = response.headers.get('content-length') + ? parseInt(response.headers.get('content-length')!) + : 0; + totalSize += size; + + entries.push({ + url: request.url, + timestamp: timestamp || Date.now(), + size, + }); + } + } + + const lastUpdated = entries.length > 0 + ? new Date(Math.max(...entries.map((e) => e.timestamp))) + : null; + + // Check if update is available (cache is older than 24 hours) + const updateAvailable = lastUpdated + ? Date.now() - lastUpdated.getTime() > CACHE_UPDATE_CHECK_INTERVAL + : false; + + setCachedDocs(entries); + setState((prev) => ({ + ...prev, + isCached: true, + lastUpdated, + cacheSize: totalSize, + updateAvailable, + error: null, + })); + } else { + setState((prev) => ({ + ...prev, + isCached: false, + lastUpdated: null, + cacheSize: 0, + updateAvailable: false, + error: null, + })); + setCachedDocs([]); + } + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to check cache status', + })); + } + }, []); + + const cacheDocs = useCallback(async (urls: string[]) => { + if (!('caches' in window)) { + setState((prev) => ({ ...prev, error: 'Cache API not available' })); + return false; + } + + setState((prev) => ({ ...prev, isUpdating: true, error: null })); + + try { + const cache = await caches.open(DOCS_CACHE_NAME); + let currentSize = 0; + + // Calculate current cache size + const existingKeys = await cache.keys(); + for (const request of existingKeys) { + const response = await cache.match(request); + if (response) { + const size = response.headers.get('content-length') + ? parseInt(response.headers.get('content-length')!) + : 0; + currentSize += size; + } + } + + for (const url of urls) { + try { + const response = await fetch(url); + if (response.ok) { + const size = response.headers.get('content-length') + ? parseInt(response.headers.get('content-length')!) + : 0; + + // Check if adding this would exceed cache limit + if (currentSize + size > MAX_CACHE_SIZE) { + console.warn(`Skipping ${url}: would exceed cache limit`); + continue; + } + + // Add timestamp header for tracking + const headers = new Headers(response.headers); + headers.set('x-cache-timestamp', Date.now().toString()); + + const cachedResponse = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + + await cache.put(url, cachedResponse); + currentSize += size; + } + } catch (error) { + console.error(`Failed to cache ${url}:`, error); + } + } + + await checkCacheStatus(); + return true; + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to cache documentation', + isUpdating: false, + })); + return false; + } finally { + setState((prev) => ({ ...prev, isUpdating: false })); + } + }, [checkCacheStatus]); + + const clearDocsCache = useCallback(async () => { + if (!('caches' in window)) { + setState((prev) => ({ ...prev, error: 'Cache API not available' })); + return false; + } + + try { + await caches.delete(DOCS_CACHE_NAME); + setState((prev) => ({ + ...prev, + isCached: false, + lastUpdated: null, + cacheSize: 0, + updateAvailable: false, + error: null, + })); + setCachedDocs([]); + return true; + } catch (error) { + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Failed to clear cache', + })); + return false; + } + }, []); + + const updateDocsCache = useCallback(async () => { + const criticalDocs = [ + '/docs/getting-started', + '/docs/api-reference', + '/docs/soroban', + '/docs/horizon', + '/docs/transactions', + '/docs/operations', + '/docs/assets', + '/docs/contracts', + '/docs/offline', + '/docs/security', + ]; + + return await cacheDocs(criticalDocs); + }, [cacheDocs]); + + const getCachedDoc = useCallback(async (url: string): Promise => { + if (!('caches' in window)) { + return null; + } + + try { + const cache = await caches.open(DOCS_CACHE_NAME); + const response = await cache.match(url); + return response || null; + } catch (error) { + console.error('Failed to get cached doc:', error); + return null; + } + }, []); + + const isDocCached = useCallback(async (url: string): Promise => { + const cached = await getCachedDoc(url); + return cached !== null; + }, [getCachedDoc]); + + return { + ...state, + cachedDocs, + cacheDocs, + clearDocsCache, + updateDocsCache, + getCachedDoc, + isDocCached, + checkCacheStatus, + }; +} \ No newline at end of file diff --git a/src/hooks/useTablePresets.ts b/src/hooks/useTablePresets.ts new file mode 100644 index 00000000..0afc5b76 --- /dev/null +++ b/src/hooks/useTablePresets.ts @@ -0,0 +1,97 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { ColumnPreset, TableDensity } from '../components/common/EnhancedTable'; + +const STORAGE_KEY = 'table-presets'; +const STATE_STORAGE_KEY = 'table-state'; + +export function useTablePresets(tableId: string, initialColumns: string[] = [], initialDensity: TableDensity = 'comfortable') { + const [presets, setPresets] = useState([]); + const [visibleColumns, setVisibleColumns] = useState(initialColumns); + const [density, setDensity] = useState(initialDensity); + + // Load presets from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const allPresets = JSON.parse(stored); + const tablePresets = allPresets[tableId] || []; + setPresets(tablePresets); + } + } catch (error) { + console.error('Failed to load table presets:', error); + } + }, [tableId]); + + // Load saved state from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem(STATE_STORAGE_KEY); + if (stored) { + const allStates = JSON.parse(stored); + const tableState = allStates[tableId]; + if (tableState) { + if (tableState.visibleColumns && tableState.visibleColumns.length > 0) { + setVisibleColumns(tableState.visibleColumns); + } + if (tableState.density) { + setDensity(tableState.density); + } + } + } + } catch (error) { + console.error('Failed to load table state:', error); + } + }, [tableId]); + + // Save state to localStorage when it changes + useEffect(() => { + try { + const stored = localStorage.getItem(STATE_STORAGE_KEY); + const allStates = stored ? JSON.parse(stored) : {}; + allStates[tableId] = { visibleColumns, density }; + localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(allStates)); + } catch (error) { + console.error('Failed to save table state:', error); + } + }, [tableId, visibleColumns, density]); + + // Save presets to localStorage + const savePresets = useCallback((newPresets: ColumnPreset[]) => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + const allPresets = stored ? JSON.parse(stored) : {}; + allPresets[tableId] = newPresets; + localStorage.setItem(STORAGE_KEY, JSON.stringify(allPresets)); + setPresets(newPresets); + } catch (error) { + console.error('Failed to save table presets:', error); + } + }, [tableId]); + + const handlePresetSave = useCallback((preset: ColumnPreset) => { + const newPresets = [...presets, preset]; + savePresets(newPresets); + }, [presets, savePresets]); + + const handlePresetDelete = useCallback((id: string) => { + const newPresets = presets.filter((p) => p.id !== id); + savePresets(newPresets); + }, [presets, savePresets]); + + const handlePresetApply = useCallback((preset: ColumnPreset) => { + setVisibleColumns(preset.columns); + setDensity(preset.density); + }, []); + + return { + presets, + visibleColumns, + setVisibleColumns, + density, + setDensity, + onPresetSave: handlePresetSave, + onPresetDelete: handlePresetDelete, + onPresetApply: handlePresetApply, + }; +} \ No newline at end of file