diff --git a/package-lock.json b/package-lock.json index 4f48a98..60224ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,21 +41,33 @@ }, "devDependencies": { "@eslint/js": "^9.30.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.7.2", "@types/proj4": "^2.5.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^25.0.1", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^5.4.10", - "vite-plugin-html": "^3.2.2" + "vite-plugin-html": "^3.2.2", + "vitest": "^2.1.9" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -70,6 +82,25 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/@azure/msal-browser": { "version": "4.16.0", "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.16.0.tgz", @@ -338,6 +369,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -386,6 +426,122 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -997,6 +1153,32 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -1030,11 +1212,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true, - "license": "MIT" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.29", @@ -1106,6 +1287,16 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -1462,6 +1653,98 @@ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1983,6 +2266,165 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.12", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", @@ -2054,6 +2496,15 @@ "node": ">=0.8" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2071,6 +2522,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2094,12 +2554,30 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2299,6 +2777,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2392,6 +2879,22 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2449,6 +2952,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -2665,6 +3177,31 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2781,6 +3318,19 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2798,6 +3348,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2816,6 +3372,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2886,6 +3451,13 @@ "jszip": ">=3.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -3009,6 +3581,12 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -3047,6 +3625,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3084,6 +3668,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3393,6 +3983,15 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -3683,6 +4282,22 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -3844,6 +4459,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3857,6 +4493,30 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", @@ -3995,6 +4655,24 @@ "he": "bin/he" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -4043,6 +4721,32 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -4106,6 +4810,15 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4179,6 +4892,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4224,6 +4946,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -4236,6 +4964,71 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.9.4", "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", @@ -4282,6 +5075,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4552,6 +5385,12 @@ "underscore": "^1.13.1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -4572,6 +5411,63 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mammoth": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", @@ -5569,6 +6465,15 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5582,6 +6487,15 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/monaco-editor": { "version": "0.55.1", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", @@ -5716,6 +6630,12 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5804,6 +6724,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -5858,6 +6784,30 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5906,6 +6856,28 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -5919,6 +6891,15 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -6106,6 +7087,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -6452,6 +7468,19 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -6607,6 +7636,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6657,6 +7692,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -6848,6 +7895,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6914,6 +7979,12 @@ "node": ">=0.8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -6928,6 +7999,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6941,6 +8018,56 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6955,6 +8082,58 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6999,6 +8178,12 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/terser": { "version": "5.44.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", @@ -7025,11 +8210,118 @@ "dev": true, "license": "MIT" }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7052,6 +8344,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7469,6 +8785,34 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, "node_modules/vite-plugin-html": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/vite-plugin-html/-/vite-plugin-html-3.2.2.tgz", @@ -7493,6 +8837,145 @@ "vite": ">=2.0.0" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7509,6 +8992,22 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wkt-parser": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.2.tgz", @@ -7541,6 +9040,106 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xlsx": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", @@ -7561,6 +9160,15 @@ "node": ">=0.8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", @@ -7569,6 +9177,12 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xstate": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz", diff --git a/package.json b/package.json index e94b6ae..2145710 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "build:prod": "tsc -b && vite build --mode prod", "build:int": "tsc -b && vite build --mode int", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@azure/msal-browser": "^4.12.0", @@ -47,18 +51,24 @@ }, "devDependencies": { "@eslint/js": "^9.30.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.7.2", "@types/proj4": "^2.5.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^2.1.9", "eslint": "^9.30.1", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", + "jsdom": "^25.0.1", "typescript": "~5.8.3", "typescript-eslint": "^8.35.1", "vite": "^5.4.10", - "vite-plugin-html": "^3.2.2" + "vite-plugin-html": "^3.2.2", + "vitest": "^2.1.9" } } diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index b6387ac..462268d 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -169,11 +169,63 @@ export interface MfaChallengeEvent { // SSE Event Types export interface TeamsbotSSEEvent { - type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed'; + type: + | 'transcript' + | 'botResponse' + | 'analysis' + | 'suggestedResponse' + | 'statusChange' + | 'error' + | 'ping' + | 'sessionState' + | 'ttsDeliveryStatus' + | 'mfaChallenge' + | 'mfaResolved' + | 'chatSendFailed' + | 'directorPrompt' + | 'agentRun' + | 'botConnectionState'; data: any; timestamp?: string; } +// ========================================================================= +// Director Prompts (private operator instructions during a live meeting) +// ========================================================================= + +export type DirectorPromptMode = 'oneShot' | 'persistent'; +export type DirectorPromptStatus = + | 'queued' + | 'running' + | 'succeeded' + | 'failed' + | 'consumed'; + +export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000; +export const DIRECTOR_PROMPT_FILE_LIMIT = 10; + +export interface DirectorPrompt { + id: string; + sessionId: string; + instanceId: string; + operatorUserId: string; + text: string; + mode: DirectorPromptMode; + fileIds: string[]; + status: DirectorPromptStatus; + statusMessage?: string; + createdAt: string; + consumedAt?: string; + agentRunId?: string; + responseText?: string; +} + +export interface DirectorPromptCreateRequest { + text: string; + mode: DirectorPromptMode; + fileIds?: string[]; +} + // ============================================================================ // API FUNCTIONS // ============================================================================ @@ -289,6 +341,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System return response.data; } +/** + * Create a new system bot account. The password is encrypted server-side + * before storage; the API never returns the password back. SysAdmin only. + */ +export async function createSystemBot( + instanceId: string, + payload: { email: string; password: string; name?: string }, +): Promise<{ bot: SystemBot }> { + const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload); + return response.data; +} + +/** + * Delete a system bot account. SysAdmin only. + */ +export async function deleteSystemBot( + instanceId: string, + botId: string, +): Promise<{ deleted: boolean }> { + const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`); + return response.data; +} + /** * Test TTS voice with AI-generated sample text. Returns base64-encoded audio. */ @@ -452,3 +527,50 @@ export async function submitMfaCode( }); return response.data; } + +// ========================================================================= +// Director Prompts +// ========================================================================= + +/** + * Submit a private director prompt to the running bot. Triggers the full + * agent path (web, mail, RAG, etc.) and delivers the answer into the meeting. + */ +export async function submitDirectorPrompt( + instanceId: string, + sessionId: string, + body: DirectorPromptCreateRequest, +): Promise<{ prompt: DirectorPrompt }> { + const response = await api.post( + `/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`, + body, + ); + return response.data; +} + +/** + * List director prompts for a session (operator's own prompts only). + */ +export async function listDirectorPrompts( + instanceId: string, + sessionId: string, +): Promise<{ prompts: DirectorPrompt[] }> { + const response = await api.get( + `/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`, + ); + return response.data; +} + +/** + * Remove a (typically persistent) director prompt. + */ +export async function deleteDirectorPrompt( + instanceId: string, + sessionId: string, + promptId: string, +): Promise<{ deleted: boolean; promptId: string }> { + const response = await api.delete( + `/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`, + ); + return response.data; +} diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index de467e5..76aa49a 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -29,6 +29,7 @@ export interface PortField { /** Plain string or per-language map from the API catalog. */ description: string | Record; required: boolean; + enumValues?: string[] | null; } export interface PortSchema { @@ -40,8 +41,14 @@ export interface InputPortDef { accepts: string[]; } +/** Graph-defined output schema (e.g. form fields from node parameters). */ +export interface GraphDefinedSchemaRef { + kind: 'fromGraph'; + parameter: string; +} + export interface OutputPortDef { - schema: string; + schema: string | GraphDefinedSchemaRef; dynamic?: boolean; deriveFrom?: string; } @@ -90,7 +97,7 @@ export interface Automation2GraphNode { type: string; parameters?: Record; inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; - outputPorts?: Array<{ name: string; schema: string }>; + outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>; } export interface Automation2Connection { @@ -109,6 +116,10 @@ export interface ExecuteGraphResponse { success: boolean; nodeOutputs?: Record; error?: string; + /** Soft, non-blocking message displayed alongside a successful response. + * Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern" + * without flipping `success` to `false`. */ + warning?: string; stopped?: boolean; failedNode?: string; paused?: boolean; @@ -264,8 +275,55 @@ export async function fetchNodeTypes( }); const nodeTypes = data?.nodeTypes ?? []; const categories = data?.categories ?? []; - console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`); - return { nodeTypes, categories }; + const portTypeCatalog = data?.portTypeCatalog ?? undefined; + const systemVariables = data?.systemVariables ?? undefined; + console.log( + `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + + `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + + `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars` + ); + return { nodeTypes, categories, portTypeCatalog, systemVariables }; +} + +export interface UpstreamPathEntry { + producerNodeId: string; + producerLabel?: string; + path: (string | number)[]; + type: string; + label: string; + scopeOrigin: 'data' | 'loop' | 'system'; +} + +/** + * POST /api/workflows/{instanceId}/upstream-paths — pickable upstream paths for DataPicker / AI. + */ +export async function postUpstreamPaths( + request: ApiRequestFunction, + instanceId: string, + graph: Automation2Graph, + nodeId: string +): Promise<{ paths: UpstreamPathEntry[] }> { + const data = await request({ + url: `/api/workflows/${instanceId}/upstream-paths`, + method: 'post', + data: { graph, nodeId }, + }); + return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; +} + +/** GET saved workflow graph variant of upstream-paths (requires workflowId). */ +export async function getUpstreamPathsSaved( + request: ApiRequestFunction, + instanceId: string, + workflowId: string, + nodeId: string +): Promise<{ paths: UpstreamPathEntry[] }> { + const data = await request({ + url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`, + method: 'get', + params: { workflowId }, + }); + return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; } /** diff --git a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx index 8fb4419..8be4ea9 100644 --- a/src/components/FlowEditor/context/Automation2DataFlowContext.tsx +++ b/src/components/FlowEditor/context/Automation2DataFlowContext.tsx @@ -6,7 +6,7 @@ import React, { createContext, useContext, useMemo } from 'react'; import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas'; import { getAvailableSources } from '../nodes/shared/dataFlowGraph'; -import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi'; +import type { ApiRequestFunction, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi'; export interface Automation2DataFlowContextValue { currentNodeId: string; @@ -19,6 +19,11 @@ export interface Automation2DataFlowContextValue { systemVariables: Record; getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string; getAvailableSourceIds: () => string[]; + /** Present when rendered inside the flow editor (ConnectionPicker / tools). */ + instanceId?: string; + request?: ApiRequestFunction; + /** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */ + parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null; } const Automation2DataFlowContext = createContext(null); @@ -36,6 +41,8 @@ interface Automation2DataFlowProviderProps { language: string; portTypeCatalog?: Record; systemVariables?: Record; + instanceId?: string; + request?: ApiRequestFunction; children: React.ReactNode; } @@ -48,10 +55,52 @@ export const Automation2DataFlowProvider: React.FC { const value = useMemo((): Automation2DataFlowContextValue | null => { if (!node) return null; + const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => { + const raw = node.parameters?.[parameterKey]; + if (!Array.isArray(raw)) return null; + const fields: PortField[] = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const rec = item as Record; + if (typeof rec.name !== 'string') continue; + const lab = rec.label; + const desc = + typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record).de ?? '') : ''; + const ftype = typeof rec.type === 'string' ? rec.type : 'str'; + if (ftype === 'group' && Array.isArray(rec.fields)) { + for (const sub of rec.fields as Record[]) { + if (!sub || typeof sub.name !== 'string') continue; + const sl = sub.label; + const sdesc = + typeof sl === 'string' + ? sl + : typeof sl === 'object' && sl !== null + ? String((sl as Record).de ?? '') + : ''; + fields.push({ + name: `${rec.name}.${sub.name}`, + type: typeof sub.type === 'string' ? sub.type : 'str', + description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`, + required: Boolean(sub.required), + }); + } + continue; + } + fields.push({ + name: rec.name, + type: ftype, + description: (desc && desc.trim()) || rec.name, + required: Boolean(rec.required), + }); + } + return fields.length ? { name: 'FormPayload_dynamic', fields } : null; + }; return { currentNodeId: node.id, nodes, @@ -64,8 +113,11 @@ export const Automation2DataFlowProvider: React.FC n.title ?? n.label ?? n.type ?? n.id, getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections), + instanceId, + request, + parseGraphDefinedSchema, }; - }, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]); + }, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, instanceId, request]); return ( diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 35e42b9..7cdf7d8 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -45,6 +45,8 @@ import { buildInvocationsForPrimaryKind, } from '../nodes/runtime/workflowStartSync'; import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry'; +import { findGraphErrors } from '../nodes/shared/paramValidation'; +import { getLabel as getParamLabel } from '../nodes/shared/utils'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { usePrompt } from '../../../hooks/usePrompt'; import { EditorChatPanel } from './EditorChatPanel'; @@ -180,6 +182,21 @@ export const Automation2FlowEditor: React.FC = ({ in [canvasNodes, nodeTypes, executeResult?.nodeOutputs] ); + // Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the + // canvas error badges and the Run-button gate. Graph-level: Save stays + // unconditional (Schicht-4 invariant: WIP must always be persistable). + const nodeErrors = useMemo( + () => + findGraphErrors( + canvasNodes, + nodeTypes, + (p) => getParamLabel(p.description, language) || p.name, + ), + [canvasNodes, nodeTypes, language] + ); + const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]); + const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]); + const applyGraphWithSync = useCallback( (graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => { const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen')); @@ -211,6 +228,19 @@ export const Automation2FlowEditor: React.FC = ({ in setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') }); return; } + // Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen. + if (Object.keys(nodeErrors).length > 0) { + const firstId = Object.keys(nodeErrors)[0]; + const firstNode = canvasNodes.find((n) => n.id === firstId); + if (firstNode) setSelectedNode(firstNode); + setExecuteResult({ + success: false, + error: + t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') + + (firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''), + }); + return; + } setExecuting(true); setExecuteResult(null); try { @@ -228,7 +258,7 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { setExecuting(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]); const handleSave = useCallback(async () => { const graph = toApiGraph(canvasNodes, canvasConnections); @@ -236,11 +266,28 @@ export const Automation2FlowEditor: React.FC = ({ in setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') }); return; } + // Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt, + // aber wir berichten die Anzahl in einem nicht-blockierenden Warning, + // damit der User die WIP-Lücken nicht stillschweigend persistiert. + const errorCount = Object.values(nodeErrors).reduce( + (acc, list) => acc + list.length, + 0, + ); + const errorNodeCount = Object.keys(nodeErrors).length; + const _buildSaveResult = (): ExecuteGraphResponse => ({ + success: true, + warning: + errorCount > 0 + ? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.') + .replace('{n}', String(errorCount)) + .replace('{m}', String(errorNodeCount)) + : undefined, + }); setSaving(true); try { if (currentWorkflowId) { await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); - setExecuteResult({ success: true } as ExecuteGraphResponse); + setExecuteResult(_buildSaveResult()); } else { const label = await promptInput(t('Workflow-Name:'), { title: t('Workflow speichern'), @@ -259,14 +306,14 @@ export const Automation2FlowEditor: React.FC = ({ in setCurrentWorkflowId(created.id); if (created.invocations?.length) setInvocations(created.invocations); setWorkflows((prev) => [...prev, created]); - setExecuteResult({ success: true } as ExecuteGraphResponse); + setExecuteResult(_buildSaveResult()); } } catch (err: unknown) { setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) }); } finally { setSaving(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]); const handleLoad = useCallback( async (workflowId: string) => { @@ -749,6 +796,17 @@ export const Automation2FlowEditor: React.FC = ({ in saving={saving} executing={executing} hasNodes={canvasNodes.length > 0} + executeBlockedReason={ + hasGraphErrors + ? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.') + : null + } + onExecuteBlockedClick={() => { + if (firstErrorNodeId) { + const n = canvasNodes.find((x) => x.id === firstErrorNodeId); + if (n) setSelectedNode(n); + } + }} executeResult={executeResult} versions={versions} currentVersionId={currentVersionId} @@ -777,6 +835,7 @@ export const Automation2FlowEditor: React.FC = ({ in getCategoryIcon={getCategoryIcon} onSelectionChange={setSelectedNode} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} + nodeErrors={nodeErrors} onExternalDrop={async (mime, payload) => { if (mime !== 'application/json+workflow' || !instanceId) return false; const p = payload as { files?: Array<{ id: string }> } | undefined; @@ -804,6 +863,8 @@ export const Automation2FlowEditor: React.FC = ({ in language={language} portTypeCatalog={portTypeCatalog as Record} systemVariables={systemVariables as Record} + instanceId={instanceId} + request={request} > ({ + useLanguage: () => ({ t: (s: string) => s }), +})); + +import { CanvasHeader } from './CanvasHeader'; + +const _workflows: Automation2Workflow[] = []; + +function _renderHeader(overrides: Partial> = {}) { + const props: React.ComponentProps = { + workflows: _workflows, + currentWorkflowId: null, + onWorkflowSelect: () => {}, + onNew: () => {}, + onSave: () => {}, + onExecute: () => {}, + saving: false, + executing: false, + hasNodes: true, + executeResult: null, + ...overrides, + }; + return render(); +} + +describe('CanvasHeader Run-button (T10)', () => { + it('runs `onExecute` when not blocked', async () => { + const onExecute = vi.fn(); + _renderHeader({ onExecute }); + await userEvent.click(screen.getByRole('button', { name: /Ausführen/i })); + expect(onExecute).toHaveBeenCalledTimes(1); + }); + + it('shows the "Pflicht-Felder fehlen" label and triggers `onExecuteBlockedClick` instead of `onExecute`', async () => { + const onExecute = vi.fn(); + const onExecuteBlockedClick = vi.fn(); + _renderHeader({ + onExecute, + onExecuteBlockedClick, + executeBlockedReason: '2 Nodes mit Pflicht-Fehlern', + }); + const btn = screen.getByRole('button', { name: /Pflicht-Felder fehlen/i }); + expect(btn).toHaveAttribute('aria-disabled', 'true'); + expect(btn).toHaveAttribute('title', '2 Nodes mit Pflicht-Fehlern'); + await userEvent.click(btn); + expect(onExecute).not.toHaveBeenCalled(); + expect(onExecuteBlockedClick).toHaveBeenCalledTimes(1); + }); + + it('disables the Run button while executing or when no nodes are present', () => { + const { rerender } = _renderHeader({ executing: true }); + expect(screen.getByRole('button', { name: /Ausführen…/i })).toBeDisabled(); + rerender( + {}} + onNew={() => {}} + onSave={() => {}} + onExecute={() => {}} + saving={false} + executing={false} + hasNodes={false} + executeResult={null} + />, + ); + expect(screen.getByRole('button', { name: /Ausführen/i })).toBeDisabled(); + }); +}); + +describe('CanvasHeader executeResult banner (AC-9)', () => { + it('renders the warning text in amber when success+warning is present', () => { + const result: ExecuteGraphResponse = { + success: true, + warning: 'Gespeichert mit 3 Pflicht-Fehlern in 2 Nodes.', + }; + _renderHeader({ executeResult: result }); + expect(screen.getByText(/Gespeichert mit 3 Pflicht-Fehlern/i)).toBeInTheDocument(); + }); + + it('renders the error text in red when success=false', () => { + const result: ExecuteGraphResponse = { success: false, error: 'Boom' }; + _renderHeader({ executeResult: result }); + expect(screen.getByText(/Boom/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/FlowEditor/editor/CanvasHeader.tsx b/src/components/FlowEditor/editor/CanvasHeader.tsx index 3ffe702..d30430e 100644 --- a/src/components/FlowEditor/editor/CanvasHeader.tsx +++ b/src/components/FlowEditor/editor/CanvasHeader.tsx @@ -21,6 +21,11 @@ interface CanvasHeaderProps { saving: boolean; executing: boolean; hasNodes: boolean; + /** Phase-4 Schicht-4: when set, the Run button is disabled and the message + * is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the + * parent can navigate the user to the first offending node. */ + executeBlockedReason?: string | null; + onExecuteBlockedClick?: () => void; executeResult: ExecuteGraphResponse | null; versions?: AutoVersion[]; currentVersionId?: string | null; @@ -56,6 +61,8 @@ export const CanvasHeader: React.FC = ({ workflows, saving, executing, hasNodes, + executeBlockedReason, + onExecuteBlockedClick, executeResult, versions, currentVersionId, @@ -213,7 +220,11 @@ export const CanvasHeader: React.FC = ({ workflows, type="button" className={styles.retryButton} onClick={onSave} - disabled={saving || !hasNodes} + // Phase-4 Schicht-4: Save niemals blockieren — work-in-progress muss + // jederzeit persistierbar sein. Nur während des Save-Requests selbst + // sperren wir den Button, um Doppelklicks zu verhindern. + disabled={saving} + title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined} > {saving ? : t('Speichern')} @@ -277,14 +288,37 @@ export const CanvasHeader: React.FC = ({ workflows, + + )} + + {isMissing && ( +
+ {t('Pflicht-Bindung fehlt — Quelle aus Upstream-Node wählen.')} +
+ )} + + + + {dataFlow && ( + setPickerOpen(false)} + onPick={onPick} + availableSourceIds={sourceIds} + nodes={dataFlow.nodes} + nodeOutputsPreview={dataFlow.nodeOutputsPreview} + getNodeLabel={dataFlow.getNodeLabel} + expectedParamType={param.type} + /> + )} + + ); +}; diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index 3d86c7f..a910ca6 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -26,6 +26,11 @@ export type FieldRendererComponent = ComponentType; import React from 'react'; import { useLanguage } from '../../../../providers/language/LanguageContext'; +import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; +import { toApiGraph } from '../shared/graphUtils'; +import { postUpstreamPaths } from '../../../../api/workflowApi'; +import type { CanvasNode } from '../../editor/FlowCanvas'; +import { DataRefRenderer } from './DataRefRenderer'; const TextInput: React.FC = ({ param, value, onChange }) => (
@@ -152,8 +157,11 @@ const HiddenInput: React.FC = () => null; const ConnectionPicker: React.FC = ({ param, value, onChange, instanceId, request }) => { const { t } = useLanguage(); + const dataFlow = useAutomation2DataFlow(); const [connections, setConnections] = React.useState>([]); const [loadError, setLoadError] = React.useState(null); + const [upstreamBindOptions, setUpstreamBindOptions] = React.useState>([]); + const autoSingleRef = React.useRef(false); const authority = (param.frontendOptions?.authority as string | undefined) || undefined; React.useEffect(() => { if (!instanceId || !request) return; @@ -170,26 +178,100 @@ const ConnectionPicker: React.FC = ({ param, value, onChange setLoadError(err instanceof Error ? err.message : String(err)); }); }, [instanceId, request, authority]); + + React.useEffect(() => { + if (!instanceId || !request || !dataFlow?.currentNodeId) { + setUpstreamBindOptions([]); + return; + } + const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections); + postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId) + .then(({ paths }) => { + const opts = paths + .filter( + (p) => + p.path.length > 0 + && (String(p.path[p.path.length - 1]) === 'id' || p.path.join('.').includes('connection')), + ) + .map((p, i) => ({ + key: `${p.producerNodeId}:${p.path.join('.')}:${i}`, + label: `${p.producerLabel ?? p.producerNodeId} → ${p.label}`, + ref: { + type: 'ref', + nodeId: p.producerNodeId, + path: p.path, + expectedType: p.type, + }, + })); + setUpstreamBindOptions(opts); + }) + .catch(() => setUpstreamBindOptions([])); + }, [instanceId, request, dataFlow?.currentNodeId, dataFlow?.nodes, dataFlow?.connections]); + + React.useEffect(() => { + if (connections.length !== 1 || autoSingleRef.current) return; + if (value !== '' && value !== undefined && value !== null) return; + autoSingleRef.current = true; + onChange(connections[0].id); + }, [connections, value, onChange]); + + const strVal = typeof value === 'string' ? value : ''; + const isRef = typeof value === 'object' && value !== null && (value as { type?: string }).type === 'ref'; + const selectedUpstreamKey = + isRef + ? upstreamBindOptions.find((o) => { + const r = o.ref as { nodeId?: string; path?: unknown[] }; + const v = value as { nodeId?: string; path?: unknown[] }; + return r.nodeId === v.nodeId && JSON.stringify(r.path ?? []) === JSON.stringify(v.path ?? []); + })?.key ?? '' + : ''; + return (
- - {!loadError && connections.length === 0 && ( -
+ {connections.length === 0 && !loadError && ( +
{authority ? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority }) : t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
)} + {connections.length === 1 && ( +
+ {connections[0].label} +
+ )} + {connections.length > 1 && ( + + )} + {upstreamBindOptions.length > 0 && ( +
+
{t('Oder aus vorherigem Node (DataRef)')}
+ +
+ )} {loadError && (
{t('Verbindungen konnten nicht geladen werden')}
)} @@ -470,12 +552,65 @@ const FieldBuilderEditor: React.FC = ({ param, value, onChan + updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} /> + {String(f.type) === 'group' && ( +
+
{t('Unterfelder')}
+ {(Array.isArray(f.fields) ? f.fields : []).map((sub: Record, j: number) => ( +
+ { + const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; + nextFields[j] = { ...sub, name: e.target.value }; + updateField(i, 'fields', nextFields); + }} + style={{ flex: 1, minWidth: 80, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} + /> + + +
+ ))} + +
+ )}
))} @@ -618,6 +753,7 @@ export const FRONTEND_TYPE_RENDERERS: Record = { json: JsonEditor, file: TextInput, hidden: HiddenInput, + dataRef: DataRefRenderer, userConnection: ConnectionPicker, sharepointFolder: SharepointPathPicker, sharepointFile: SharepointPathPicker, @@ -630,6 +766,7 @@ export const FRONTEND_TYPE_RENDERERS: Record = { condition: ConditionBuilder, mappingTable: MappingTableEditor, filterExpression: FilterExpressionEditor, + attachmentBuilder: JsonEditor, }; export default FRONTEND_TYPE_RENDERERS; diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx new file mode 100644 index 0000000..2df5ba5 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/DataPicker.test.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Plan #2 — Track A1.2 / A1.3 +// T7: DataPicker strict-type filtering (only compatible candidates rendered). +// T8: DataPicker generic object drill-down via wildcard '*' segment when the +// schema declares List[X] of a known X. + +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; +import type { DataRef, SystemVarRef } from './dataRef'; + +vi.mock('../../../../providers/language/LanguageContext', () => ({ + useLanguage: () => ({ t: (s: string) => s }), +})); + +let _ctxValue: unknown = null; +vi.mock('../../context/Automation2DataFlowContext', () => ({ + useAutomation2DataFlow: () => _ctxValue, +})); + +import { DataPicker } from './DataPicker'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _field(name: string, type: string): PortField { + return { name, type, description: '', required: false }; +} + +const _docListSchema: PortSchema = { + name: 'DocumentList', + fields: [ + _field('documents', 'List[UdmDocument]'), + _field('count', 'int'), + _field('meta', 'str'), + ], +}; +const _udmDocumentSchema: PortSchema = { + name: 'UdmDocument', + fields: [ + _field('name', 'str'), + _field('mimeType', 'str'), + _field('sizeBytes', 'int'), + ], +}; + +const _portCatalog: Record = { + DocumentList: _docListSchema, + UdmDocument: _udmDocumentSchema, +}; + +function _setContext(opts: { + consumerNodeId: string; + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; +}) { + _ctxValue = { + currentNodeId: opts.consumerNodeId, + nodes: opts.nodes, + connections: opts.connections, + nodeTypes: opts.nodeTypes, + portTypeCatalog: _portCatalog, + nodeOutputsPreview: {}, + systemVariables: {}, + language: 'de', + getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id, + getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id), + parseGraphDefinedSchema: () => null, + }; +} + +function _node(id: string, type: string): CanvasNode { + return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} }; +} +function _conn(id: string, src: string, tgt: string): CanvasConnection { + return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 }; +} +function _nodeType(id: string, outputSchema: string): NodeType { + return { + id, + label: id, + description: id, + category: 'test', + parameters: [], + inputs: 1, + outputs: 1, + outputPorts: [{ schema: outputSchema }], + } as unknown as NodeType; +} + +function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) { + const upstream = _node('up', 'sharepoint.readDocs'); + const consumer = _node('cons', 'ai.summarize'); + _setContext({ + consumerNodeId: 'cons', + nodes: [upstream, consumer], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')], + }); + return render( + {}} + onPick={props?.onPick ?? (() => {})} + availableSourceIds={['up']} + nodes={[upstream]} + nodeOutputsPreview={{}} + getNodeLabel={(n) => n.title ?? n.id} + expectedParamType={props?.expectedParamType} + />, + ); +} + +// --------------------------------------------------------------------------- +// T8: Wildcard drill-down +// --------------------------------------------------------------------------- + +describe('DataPicker — generic-object drill-down (T8)', () => { + it('renders the wildcard "documents → * → name" path when drilling into List[UdmDocument]', async () => { + _renderPicker(); + await userEvent.click(screen.getByText(/^up$/)); + expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument(); + expect(screen.getByText(/documents → \* → mimeType/)).toBeInTheDocument(); + }); + + it('lists the wholeOutput, top-level fields, and drilled fields together', async () => { + _renderPicker(); + await userEvent.click(screen.getByText(/^up$/)); + expect(screen.getByText('documents')).toBeInTheDocument(); + expect(screen.getByText('count')).toBeInTheDocument(); + expect(screen.getByText('meta')).toBeInTheDocument(); + // Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error). + expect(screen.getAllByText(/documents → \*/).length).toBeGreaterThanOrEqual(2); + }); +}); + +// --------------------------------------------------------------------------- +// T7: Strict type filter +// --------------------------------------------------------------------------- + +describe('DataPicker — strict type filtering (T7)', () => { + it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => { + _renderPicker({ expectedParamType: 'str' }); + expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked(); + await userEvent.click(screen.getByText(/^up$/)); + // documents (List[UdmDocument]) is a hard mismatch → must be hidden. + expect(screen.queryByText('documents')).not.toBeInTheDocument(); + // meta (str) is exact match → kept. + expect(screen.getByText('meta')).toBeInTheDocument(); + // count (int) is "coerce" against str → kept (coerce is allowed in strict mode). + expect(screen.getByText('count')).toBeInTheDocument(); + // Drilled wildcard candidates of type str (name, mimeType) remain. + expect(screen.getByText(/documents → \* → name/)).toBeInTheDocument(); + }); + + it('shows all fields after the user disables the strict toggle', async () => { + _renderPicker({ expectedParamType: 'str' }); + await userEvent.click(screen.getByLabelText(/Nur kompatible/i)); + await userEvent.click(screen.getByText(/^up$/)); + expect(screen.getByText('documents')).toBeInTheDocument(); + expect(screen.getByText('count')).toBeInTheDocument(); + expect(screen.getByText('meta')).toBeInTheDocument(); + }); + + it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => { + _renderPicker({ expectedParamType: 'UdmDocument' }); + await userEvent.click(screen.getByText(/^up$/)); + // documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument + expect(screen.getByText('documents')).toBeInTheDocument(); + expect(screen.getByText('iterieren')).toBeInTheDocument(); + }); + + it('emits a wildcard ref when the user clicks "iterieren"', async () => { + const onPick = vi.fn(); + _renderPicker({ expectedParamType: 'UdmDocument', onPick }); + await userEvent.click(screen.getByText(/^up$/)); + await userEvent.click(screen.getByText('iterieren')); + expect(onPick).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'ref', + nodeId: 'up', + path: ['documents', '*'], + expectedType: 'UdmDocument', + }), + ); + }); +}); diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx index 8735008..c1ea657 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx @@ -5,10 +5,11 @@ * Includes a System Variables section. */ -import React, { useState } from 'react'; -import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef'; +import React, { useMemo, useState } from 'react'; +import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; -import type { NodeType, PortSchema } from '../../../../api/workflowApi'; +import type { GraphDefinedSchemaRef, NodeType, PortSchema } from '../../../../api/workflowApi'; +import { findLoopAncestorIds } from './scopeHelpers'; import styles from '../../editor/Automation2FlowEditor.module.css'; import { useLanguage } from '../../../../providers/language/LanguageContext'; @@ -18,33 +19,103 @@ interface DataPickerProps { onClose: () => void; onPick: (ref: DataRef | SystemVarRef) => void; availableSourceIds: string[]; - nodes: Array<{ id: string; title?: string; type?: string }>; + nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record }>; nodeOutputsPreview: Record; getNodeLabel: (node: { id: string; title?: string }) => string; + /** When set, the picker can hide incompatible candidates (strict toggle) and + * surfaces "Iterieren als Loop" affordances for List[X]→X candidates. */ + expectedParamType?: string; } interface PickablePath { path: (string | number)[]; label: string; type?: string; + /** True iff this path produces `List[X]` and the consumer expects `X` — + * picking with iterate=true appends the wildcard segment. */ + iterable?: boolean; } +const _LIST_INNER_RE = /^List\[(.+)\]$/; + function _buildPathsFromSchema( schema: PortSchema | undefined, + catalog: Record, basePath: (string | number)[] = [], + depth = 0, ): PickablePath[] { - if (!schema || !schema.fields) return []; + if (!schema || !schema.fields || depth > 8) return []; const result: PickablePath[] = []; for (const field of schema.fields) { const fieldPath = [...basePath, field.name]; const label = fieldPath.map(String).join(' → '); result.push({ path: fieldPath, label, type: field.type }); + const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null; + const inner = m?.[1]?.trim(); + if (inner && catalog[inner]) { + // Generic List drill-down: use '*' wildcard so the engine maps each item. + result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); + } } result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' }); result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' }); return result; } +/** Annotate each candidate with `iterable=true` if it is `List[X]` and the + * consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */ +function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] { + if (!expectedParamType) return paths; + return paths.map((p) => { + if (!p.type) return p; + const m = p.type.match(_LIST_INNER_RE); + if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true }; + return p; + }); +} + +function _deriveFormPortSchemaFromParams( + node: { parameters?: Record }, + paramKey: string, +): PortSchema | undefined { + const raw = node.parameters?.[paramKey]; + if (!Array.isArray(raw)) return undefined; + const fields: Array<{ name: string; type: string; description: string | Record; required: boolean }> = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const rec = item as Record; + if (typeof rec.name !== 'string') continue; + const lab = rec.label; + let description: string | Record = rec.name; + if (typeof lab === 'string') description = lab; + else if (lab && typeof lab === 'object') description = lab as Record; + const ftype = typeof rec.type === 'string' ? rec.type : 'str'; + if (ftype === 'group' && Array.isArray(rec.fields)) { + for (const sub of rec.fields as Record[]) { + if (!sub || typeof sub.name !== 'string') continue; + const sl = sub.label; + let sdesc: string | Record = `${rec.name}.${sub.name}`; + if (typeof sl === 'string') sdesc = sl; + else if (sl && typeof sl === 'object') sdesc = sl as Record; + fields.push({ + name: `${rec.name}.${sub.name}`, + type: typeof sub.type === 'string' ? sub.type : 'str', + description: sdesc, + required: Boolean(sub.required), + }); + } + continue; + } + fields.push({ + name: rec.name, + type: ftype, + description, + required: Boolean(rec.required), + }); + } + return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined; +} + function _buildPathsFromPreview( obj: unknown, basePath: (string | number)[] = [], @@ -74,7 +145,7 @@ function _buildPathsFromPreview( function _resolveSchemaForNode( nodeId: string, - nodes: Array<{ id: string; type?: string }>, + nodes: Array<{ id: string; type?: string; parameters?: Record }>, nodeTypes: NodeType[], connections: Array<{ source: string; target: string; sourceOutput?: number }>, catalog: Record, @@ -88,11 +159,23 @@ function _resolveSchemaForNode( const typeDef = nodeTypes.find((nt) => nt.id === node.type); if (!typeDef?.outputPorts) return undefined; - const port0 = typeDef.outputPorts[0]; + const port0 = typeDef.outputPorts[0] as { + schema?: string | GraphDefinedSchemaRef; + dynamic?: boolean; + deriveFrom?: string; + }; if (!port0) return undefined; - if (port0.schema !== 'Transit') { - return catalog[port0.schema]; + const schemaSpec = port0.schema; + if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') { + const paramKey = schemaSpec.parameter ?? 'fields'; + return _deriveFormPortSchemaFromParams(node, paramKey); + } + if (port0.dynamic && port0.deriveFrom) { + return _deriveFormPortSchemaFromParams(node, port0.deriveFrom); + } + if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') { + return catalog[schemaSpec]; } // Transit: follow the incoming connection to find the real producer @@ -108,23 +191,42 @@ export const DataPicker: React.FC = ({ open, nodes, nodeOutputsPreview, getNodeLabel, + expectedParamType, }) => { const { t } = useLanguage(); const [expandedNodes, setExpandedNodes] = useState>(new Set()); const [showSystem, setShowSystem] = useState(false); + // Default: when the consumer declares an expected type, show only compatible + // candidates ("strict" mode). User can override per-session via the toggle. + const [strictFilter, setStrictFilter] = useState(Boolean(expectedParamType)); const ctx = useAutomation2DataFlow(); + // NOTE: All hooks must be called unconditionally on every render to satisfy + // the Rules of Hooks. The `if (!open) return null;` early-return therefore + // has to live BELOW every hook in this component. Adding a useMemo (or any + // other hook) below it would change the hook count when the picker toggles + // open/closed and crash the whole tree (white screen). + const connectionsRaw = ctx?.connections ?? []; + const connections = useMemo( + () => + connectionsRaw.map((c) => ({ + source: c.sourceId, + target: c.targetId, + sourceOutput: c.sourceHandle, + })), + [connectionsRaw], + ); + const loopAncestorIds = useMemo(() => { + const cid = ctx?.currentNodeId; + if (!cid) return [] as string[]; + return findLoopAncestorIds(nodes, connections, cid); + }, [ctx?.currentNodeId, nodes, connections]); + if (!open) return null; const catalog = ctx?.portTypeCatalog ?? {}; const systemVars = ctx?.systemVariables ?? {}; const nodeTypes = ctx?.nodeTypes ?? []; - const connectionsRaw = ctx?.connections ?? []; - const connections = connectionsRaw.map((c) => ({ - source: c.sourceId, - target: c.targetId, - sourceOutput: c.sourceHandle, - })); const toggleExpand = (nodeId: string) => { setExpandedNodes((prev) => { @@ -135,8 +237,15 @@ export const DataPicker: React.FC = ({ open, }); }; - const handlePick = (nodeId: string, path: (string | number)[]) => { - onPick(createRef(nodeId, path)); + const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => { + onPick(createRef(nodeId, path, expectedType)); + onClose(); + }; + + /** Loop-Vorschlag: for List[X]→X candidates, append the '*' wildcard so the + * engine maps the consumer over each element (executionEngine wildcard). */ + const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => { + onPick(createRef(nodeId, [...path, '*'], expectedType)); onClose(); }; @@ -149,13 +258,92 @@ export const DataPicker: React.FC = ({ open,
e.stopPropagation()}>
-

{t('Datenquelle wählen')}

- +

+ {t('Datenquelle wählen')} + {expectedParamType && ( + + {expectedParamType} + + )} +

+
+ {expectedParamType && ( + + )} + +
{/* System Variables Section */} + {loopAncestorIds.length > 0 && ( +
+
+ {t('Schleife (lexikalisch)')} +
+
+ {loopAncestorIds.map((loopId) => { + const loopNode = nodes.find((n) => n.id === loopId); + const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId; + const loopSchema = catalog.LoopItem; + const loopPaths = loopSchema + ? _buildPathsFromSchema(loopSchema, catalog, [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_')) + : [ + { path: ['currentItem'], label: 'currentItem', type: 'Any' }, + { path: ['currentIndex'], label: 'currentIndex', type: 'int' }, + { path: ['count'], label: 'count', type: 'int' }, + ]; + return ( +
+
{loopLabel}
+ {loopPaths.map((p, i) => { + const compat = expectedParamType && p.type + ? isCompatible(p.type, expectedParamType) + : 'ok'; + return ( + + ); + })} +
+ ); + })} +
+
+ )} + {Object.keys(systemVars).length > 0 && (
{isExpanded && (
- {paths.map((p, i) => ( - - ))} + {paths.length === 0 && ( +
+ {t('(keine kompatiblen Felder — Filter „Nur kompatible“ deaktivieren)')} +
+ )} + {paths.map((p, i) => { + const compat = + expectedParamType && p.type ? isCompatible(p.type, expectedParamType) : 'ok'; + return ( +
+ + {p.iterable && ( + + )} +
+ ); + })}
)}
diff --git a/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx new file mode 100644 index 0000000..e7c2e77 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.test.tsx @@ -0,0 +1,243 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker. +// Validates the 0/1/N rendering logic that orchestrates DataPicker selection +// + the iterierens-suggestion (T5, T6). +// +// We mock the two consumed contexts (LanguageContext + Automation2DataFlow) +// and the DataPicker child so we can assert on the picker UI in isolation. + +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; +import type { DataRef, SystemVarRef } from './dataRef'; + +// --------------------------------------------------------------------------- +// Module mocks — must be registered before importing the SUT +// --------------------------------------------------------------------------- + +vi.mock('../../../../providers/language/LanguageContext', () => ({ + useLanguage: () => ({ t: (s: string) => s }), +})); + +let _ctxValue: unknown = null; +vi.mock('../../context/Automation2DataFlowContext', () => ({ + useAutomation2DataFlow: () => _ctxValue, +})); + +vi.mock('./DataPicker', () => ({ + DataPicker: (props: { + open: boolean; + onClose: () => void; + onPick: (ref: DataRef | SystemVarRef) => void; + }) => { + if (!props.open) return null; + return ( +
+ + +
+ ); + }, +})); + +// SUT imported AFTER mocks (so mocks are applied) +import { RequiredAttributePicker } from './RequiredAttributePicker'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _field(name: string, type: string): PortField { + return { name, type, description: '', required: false }; +} + +const _docListSchema: PortSchema = { + name: 'DocumentList', + fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')], +}; +const _udmDocumentSchema: PortSchema = { + name: 'UdmDocument', + fields: [_field('name', 'str'), _field('mimeType', 'str')], +}; +const _portCatalog: Record = { + DocumentList: _docListSchema, + UdmDocument: _udmDocumentSchema, +}; + +function _setContext(opts: { + consumerNodeId: string; + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; +}) { + _ctxValue = { + currentNodeId: opts.consumerNodeId, + nodes: opts.nodes, + connections: opts.connections, + nodeTypes: opts.nodeTypes, + portTypeCatalog: _portCatalog, + nodeOutputsPreview: {}, + systemVariables: {}, + language: 'de', + getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id, + getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId), + parseGraphDefinedSchema: () => null, + }; +} + +function _node(id: string, type: string): CanvasNode { + return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} }; +} + +function _conn(id: string, src: string, tgt: string): CanvasConnection { + return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 }; +} + +function _nodeType(id: string, outputSchema: string): NodeType { + return { + id, + label: id, + description: id, + category: 'test', + parameters: [], + inputs: 1, + outputs: 1, + outputPorts: [{ schema: outputSchema }], + } as unknown as NodeType; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => { + it('shows red "no source" pill when no upstream candidate matches (0-case)', () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('cons', 'ai.summarizeDocument')], + connections: [], + nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')], + }); + render( + {}} + />, + ); + expect( + screen.getByText(/Keine typkompatible Quelle vorhanden/i), + ).toBeInTheDocument(); + }); + + it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + render( + {}} + />, + ); + expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument(); + }); + + it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + render( + {}} + />, + ); + expect(screen.getByText(/iterieren/i)).toBeInTheDocument(); + }); + + it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByText('up')).toBeInTheDocument(); + const clearButton = screen.getByTitle(/Bindung entfernen/i); + await userEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith(null); + }); + + it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => { + _setContext({ + consumerNodeId: 'cons', + nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')], + connections: [_conn('c1', 'up', 'cons')], + nodeTypes: [ + _nodeType('sharepoint.readDocs', 'DocumentList'), + _nodeType('ai.summarizeDocument', 'AiResult'), + ], + }); + const onChange = vi.fn(); + render( + , + ); + const otherButton = screen.getByText(/Andere wählen…/i); + await userEvent.click(otherButton); + expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument(); + await userEvent.click(screen.getByText('mock-pick')); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }), + ); + }); +}); diff --git a/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx new file mode 100644 index 0000000..81236c4 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/RequiredAttributePicker.tsx @@ -0,0 +1,228 @@ +/** + * RequiredAttributePicker — Phase-4 Schicht-4 binding affordance for + * required parameters of a Schicht-3 Adapter (Editor-Node). + * + * 0/1/N logic, applied on the set of typed source candidates: + * - 0 candidates → red pill: "Keine typkompatible Quelle vorhanden" + * (user must add an upstream node first) + * - 1 candidate → auto-bound chip with a "Andere wählen…" override button + * (still shown explicitly so the user sees what was chosen) + * - N candidates → "Quelle wählen…" button that opens the DataPicker + * pre-filtered to the expected type + * + * The picker also surfaces a "Iterieren als Loop" hint when the expected type + * is `X` and an upstream candidate is `List[X]` — see paramValidation.ts. + */ + +import React, { useMemo, useState } from 'react'; +import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; +import { DataPicker } from './DataPicker'; +import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef'; +import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation'; +import styles from '../../editor/Automation2FlowEditor.module.css'; + +import { useLanguage } from '../../../../providers/language/LanguageContext'; + +export interface RequiredAttributePickerProps { + /** Display label for the parameter (already localized). */ + label: string; + /** Type expected by the bound action argument (e.g. "DocumentList", "str"). */ + expectedType?: string; + /** Current bound value (DataRef, SystemVarRef, or unset). */ + value: unknown; + /** Persist a new binding (or `null` to clear). */ + onChange: (next: DataRef | SystemVarRef | null) => void; + /** Optional description shown beneath the picker. */ + description?: React.ReactNode; +} + +export const RequiredAttributePicker: React.FC = ({ + label, + expectedType, + value, + onChange, + description, +}) => { + const { t } = useLanguage(); + const ctx = useAutomation2DataFlow(); + const [pickerOpen, setPickerOpen] = useState(false); + + const consumerNodeId = ctx?.currentNodeId ?? ''; + const nodes = ctx?.nodes ?? []; + const connections = ctx?.connections ?? []; + const nodeTypes = ctx?.nodeTypes ?? []; + const catalog = ctx?.portTypeCatalog ?? {}; + + const allCandidates: SourceCandidate[] = useMemo(() => { + if (!consumerNodeId) return []; + return findSourceCandidates({ + consumerNodeId, + expectedType, + nodes, + connections: connections.map((c) => ({ + id: c.id, + sourceId: c.sourceId, + sourceHandle: c.sourceHandle, + targetId: c.targetId, + targetHandle: c.targetHandle, + })), + nodeTypes, + portTypeCatalog: catalog, + }); + }, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]); + + const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]); + + const isBoundRef = isRef(value); + const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null; + + // 0/1/N + const candidateCount = compatibleCandidates.length; + const single = candidateCount === 1 ? compatibleCandidates[0] : null; + + const handleAutoBind = () => { + if (!single) return; + const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType); + onChange(ref); + }; + + const handlePicked = (picked: DataRef | SystemVarRef) => { + onChange(picked); + }; + + return ( +
+
+ + {expectedType && ( + + {expectedType} + + )} +
+ + {isBoundRef ? ( +
+ + {boundLabel} + + + +
+ ) : candidateCount === 0 ? ( +
+ + + {t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')} + {expectedType ?? '?'} + {t(' liefert.')} + +
+ ) : single ? ( +
+ + +
+ ) : ( +
+ +
+ )} + + {description &&
{description}
} + + {pickerOpen && ( + setPickerOpen(false)} + onPick={(picked) => { + handlePicked(picked); + setPickerOpen(false); + }} + availableSourceIds={ctx?.getAvailableSourceIds() ?? []} + nodes={nodes} + nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}} + getNodeLabel={(n) => + ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id + } + expectedParamType={expectedType} + /> + )} +
+ ); +}; diff --git a/src/components/FlowEditor/nodes/shared/dataRef.ts b/src/components/FlowEditor/nodes/shared/dataRef.ts index 72fd11b..097fbe2 100644 --- a/src/components/FlowEditor/nodes/shared/dataRef.ts +++ b/src/components/FlowEditor/nodes/shared/dataRef.ts @@ -8,6 +8,8 @@ export interface DataRef { type: 'ref'; nodeId: string; path: (string | number)[]; + /** Optional declared type at bind time (for UI / validation hints) */ + expectedType?: string; } /** Explicit static value wrapper */ @@ -63,8 +65,18 @@ export function createSystemVar(variable: string): SystemVarRef { } /** Create a reference object */ -export function createRef(nodeId: string, path: (string | number)[] = []): DataRef { - return { type: 'ref', nodeId, path }; +export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef { + return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) }; +} + +/** Structural type compatibility (best-effort; same as gateway soft rules). */ +export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' { + if (!expectedType || !producedType) return 'ok'; + if (producedType === expectedType) return 'ok'; + if (expectedType === 'Any' || producedType === 'Any') return 'ok'; + if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce'; + if (expectedType === 'int' && producedType === 'str') return 'coerce'; + return 'mismatch'; } /** Create a value wrapper */ diff --git a/src/components/FlowEditor/nodes/shared/graphUtils.ts b/src/components/FlowEditor/nodes/shared/graphUtils.ts index 14524c3..2f63a22 100644 --- a/src/components/FlowEditor/nodes/shared/graphUtils.ts +++ b/src/components/FlowEditor/nodes/shared/graphUtils.ts @@ -8,6 +8,7 @@ import type { Automation2Graph, Automation2GraphNode, Automation2Connection, + GraphDefinedSchemaRef, } from '../../../../api/workflowApi'; import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas'; @@ -42,7 +43,10 @@ export function fromApiGraph( ? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts })) : undefined, outputPorts: nt?.outputPorts - ? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' })) + ? Object.entries(nt.outputPorts).map(([, v]) => ({ + name: '', + schema: (v as { schema?: string | GraphDefinedSchemaRef }).schema ?? '', + })) : undefined, }; }); diff --git a/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts b/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts index 3f02c89..e0bcd44 100644 --- a/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts +++ b/src/components/FlowEditor/nodes/shared/outputPreviewRegistry.ts @@ -69,6 +69,10 @@ export function buildNodeOutputPreview( return { _transit: true, _meta: {}, data: {} }; } + if (typeof port0.schema !== 'string') { + return {}; + } + return _buildSchemaPreview(port0.schema); } diff --git a/src/components/FlowEditor/nodes/shared/paramValidation.test.ts b/src/components/FlowEditor/nodes/shared/paramValidation.test.ts new file mode 100644 index 0000000..86a7235 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/paramValidation.test.ts @@ -0,0 +1,304 @@ +// Copyright (c) 2025 Patrick Motsch +// All rights reserved. +// +// Plan #2 — Track A1 / FE-Tests +// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion +// T7 (DataPicker): strict type filtering +// T8 (DataPicker): generic-object drill-down via wildcard segment '*' +// +// We test the pure helpers in paramValidation.ts directly. The component +// pickers are thin shells over these helpers, so covering the helpers covers +// the deterministic core of the binding affordance. + +import { describe, expect, it } from 'vitest'; + +import { + findGraphErrors, + findRequiredErrors, + findSourceCandidates, + isParamBound, + strictlyCompatible, + type SourceCandidate, +} from './paramValidation'; +import { createRef, createSystemVar, createValue } from './dataRef'; +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function _field(name: string, type: string): PortField { + return { name, type, description: '', required: false }; +} + +function _schema(name: string, fields: PortField[]): PortSchema { + return { name, fields }; +} + +const _docListSchema: PortSchema = _schema('DocumentList', [ + _field('documents', 'List[UdmDocument]'), + _field('count', 'int'), +]); + +const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [ + _field('name', 'str'), + _field('mimeType', 'str'), + _field('sizeBytes', 'int'), +]); + +const _aiResultSchema: PortSchema = _schema('AiResult', [ + _field('text', 'str'), + _field('tokensUsed', 'int'), +]); + +const _portCatalog: Record = { + DocumentList: _docListSchema, + UdmDocument: _udmDocumentSchema, + AiResult: _aiResultSchema, +}; + +function _makeNode(id: string, type: string, parameters: Record = {}): CanvasNode { + return { + id, + type, + title: `${id} (${type})`, + x: 0, + y: 0, + inputs: 1, + outputs: 1, + parameters, + }; +} + +function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection { + return { + id, + sourceId, + sourceHandle: 0, + targetId, + targetHandle: 0, + }; +} + +function _makeNodeType( + id: string, + outputSchema: string, + parameters: NodeType['parameters'] = [], +): NodeType { + return { + id, + label: id, + description: id, + category: 'test', + parameters, + inputs: 1, + outputs: 1, + outputPorts: [{ schema: outputSchema }], + } as unknown as NodeType; +} + +// --------------------------------------------------------------------------- +// isParamBound +// --------------------------------------------------------------------------- + +describe('isParamBound', () => { + it('returns false for null/undefined/empty string', () => { + expect(isParamBound(null)).toBe(false); + expect(isParamBound(undefined)).toBe(false); + expect(isParamBound('')).toBe(false); + }); + + it('returns true for non-empty string/number/boolean', () => { + expect(isParamBound('hello')).toBe(true); + expect(isParamBound(0)).toBe(true); + expect(isParamBound(false)).toBe(true); + }); + + it('returns true for a valid DataRef and false for one without nodeId', () => { + expect(isParamBound(createRef('node-1', ['x']))).toBe(true); + expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false); + }); + + it('returns true for a SystemVarRef with a variable name', () => { + expect(isParamBound(createSystemVar('user.id'))).toBe(true); + expect(isParamBound({ type: 'system', variable: '' })).toBe(false); + }); + + it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => { + expect(isParamBound(createValue(''))).toBe(false); + expect(isParamBound(createValue(0))).toBe(true); + expect(isParamBound(createValue([]))).toBe(false); + expect(isParamBound(createValue(['a']))).toBe(true); + }); + + it('counts non-empty arrays/objects as bound', () => { + expect(isParamBound([])).toBe(false); + expect(isParamBound([1])).toBe(true); + expect(isParamBound({})).toBe(false); + expect(isParamBound({ k: 1 })).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// findRequiredErrors / findGraphErrors +// --------------------------------------------------------------------------- + +describe('findRequiredErrors', () => { + it('returns empty when all required params are bound', () => { + const node = _makeNode('n1', 'ai.process', { + aiPrompt: 'hello', + documentList: createRef('upstream', ['documents']), + }); + const nodeType = _makeNodeType('ai.process', 'AiResult', [ + { name: 'aiPrompt', type: 'str', required: true }, + { name: 'documentList', type: 'DocumentList', required: true }, + { name: 'optional', type: 'str', required: false }, + ]); + expect(findRequiredErrors(node, nodeType)).toEqual([]); + }); + + it('flags every unbound required param with its name + type', () => { + const node = _makeNode('n1', 'ai.process', {}); + const nodeType = _makeNodeType('ai.process', 'AiResult', [ + { name: 'aiPrompt', type: 'str', required: true }, + { name: 'documentList', type: 'DocumentList', required: true }, + { name: 'optional', type: 'str', required: false }, + ]); + const errs = findRequiredErrors(node, nodeType); + expect(errs).toHaveLength(2); + expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']); + }); + + it('returns empty list when nodeType is unknown', () => { + const node = _makeNode('n1', 'ghost.node'); + expect(findRequiredErrors(node, undefined)).toEqual([]); + }); +}); + +describe('findGraphErrors', () => { + it('aggregates per-node errors and omits clean nodes', () => { + const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [ + { name: 'p1', type: 'str', required: true }, + ]); + const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [ + { name: 'p1', type: 'str', required: true }, + { name: 'p2', type: 'str', required: true }, + ]); + const nodes: CanvasNode[] = [ + _makeNode('clean', 'clean.node', { p1: 'value' }), + _makeNode('dirty', 'dirty.node', { p1: 'set' }), + ]; + const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]); + expect(Object.keys(result)).toEqual(['dirty']); + expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']); + }); +}); + +// --------------------------------------------------------------------------- +// findSourceCandidates — T5/T6/T7/T8 core +// --------------------------------------------------------------------------- + +describe('findSourceCandidates', () => { + function _makeFixture() { + const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList'); + const consumerType = _makeNodeType('ai.summarize', 'AiResult', [ + { name: 'documentList', type: 'DocumentList', required: true }, + ]); + const upstream = _makeNode('up', 'sharepoint.readDocs'); + const consumer = _makeNode('cons', 'ai.summarize'); + const conns = [_makeConnection('c1', 'up', 'cons')]; + return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] }; + } + + it('returns the whole-output candidate first (path=[]) for the upstream', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + expectedType: 'DocumentList', + ...f, + portTypeCatalog: _portCatalog, + }); + const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0); + expect(wholeOutput).toBeDefined(); + expect(wholeOutput!.type).toBe('DocumentList'); + expect(wholeOutput!.compat).toBe('ok'); + }); + + it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + expectedType: 'str', + ...f, + portTypeCatalog: _portCatalog, + }); + const wildcardCandidate = candidates.find( + (c) => + c.nodeId === 'up' && + c.path[0] === 'documents' && + c.path[1] === '*' && + c.path[2] === 'name', + ); + expect(wildcardCandidate).toBeDefined(); + expect(wildcardCandidate!.type).toBe('str'); + expect(wildcardCandidate!.compat).toBe('ok'); + }); + + it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + expectedType: 'UdmDocument', + ...f, + portTypeCatalog: _portCatalog, + }); + const iterable = candidates.find( + (c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable, + ); + expect(iterable).toBeDefined(); + expect(iterable!.type).toBe('List[UdmDocument]'); + }); + + it('returns no candidates when no upstream is connected (T5: 0-case)', () => { + const f = _makeFixture(); + const isolated = _makeNode('iso', 'ai.summarize'); + const candidates = findSourceCandidates({ + consumerNodeId: 'iso', + expectedType: 'DocumentList', + nodes: [...f.nodes, isolated], + connections: f.connections, + nodeTypes: f.nodeTypes, + portTypeCatalog: _portCatalog, + }); + expect(candidates).toEqual([]); + }); + + it('returns plain candidates (compat="ok") when expectedType is omitted', () => { + const f = _makeFixture(); + const candidates = findSourceCandidates({ + consumerNodeId: 'cons', + ...f, + portTypeCatalog: _portCatalog, + }); + expect(candidates.length).toBeGreaterThan(0); + expect(candidates.every((c) => c.compat === 'ok')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// strictlyCompatible — T7 strict type filter +// --------------------------------------------------------------------------- + +describe('strictlyCompatible', () => { + it('keeps only ok / coerce / iterable candidates and drops mismatch', () => { + const all: SourceCandidate[] = [ + { nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' }, + { nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true }, + { nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' }, + { nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' }, + ]; + const out = strictlyCompatible(all); + expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]); + }); +}); diff --git a/src/components/FlowEditor/nodes/shared/paramValidation.ts b/src/components/FlowEditor/nodes/shared/paramValidation.ts new file mode 100644 index 0000000..8b8c2d6 --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/paramValidation.ts @@ -0,0 +1,208 @@ +/** + * Phase-4 Schicht-4 (Instanz-Bindings) — Validation utilities. + * + * Single source of truth for two questions every UI surface needs to answer: + * 1. "Is this required parameter on this node bound to anything?" + * 2. "Which upstream nodes are type-compatible sources for this parameter?" + * + * Used by: + * - RequiredAttributePicker (renders 0/1/N affordance based on candidate count) + * - NodeConfigPanel (orders required params first, surfaces missing-source pill) + * - FlowCanvas (red error badge per node when any required param is unbound) + * - CanvasHeader (Run button disabled when any node has unbound required params) + * + * The required check is deliberately conservative: a param counts as "bound" + * if it has any non-empty value, a non-empty static value-wrapper, a ref, or a + * system-var ref. Empty string / null / undefined / { type: 'value', value: '' } + * all count as unbound. + */ + +import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; +import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, OutputPortDef, PortSchema } from '../../../../api/workflowApi'; +import { isCompatible, isRef, isSystemVar, isValue } from './dataRef'; +import { getAvailableSources } from './dataFlowGraph'; + +const _LIST_INNER_RE = /^List\[(.+)\]$/; + +/** A candidate path on an upstream node that could satisfy a parameter binding. */ +export interface SourceCandidate { + nodeId: string; + /** JSON path on the node output, e.g. ['documents', 0, 'name']. */ + path: (string | number)[]; + /** Type as declared by the schema field at this path (best-effort). */ + type?: string; + /** Compatibility verdict against the requested type. */ + compat: 'ok' | 'coerce' | 'mismatch'; + /** True iff the candidate is a List that, by element-iteration ('*'), would + * satisfy the requested scalar type — the "iterieren als Loop-Vorschlag". */ + iterable?: boolean; +} + +/** Decide whether a parameter value counts as "bound" for required-check purposes. */ +export function isParamBound(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === 'string') return value.length > 0; + if (typeof value === 'number' || typeof value === 'boolean') return true; + if (isRef(value)) return Boolean(value.nodeId); + if (isSystemVar(value)) return Boolean(value.variable); + if (isValue(value)) { + const inner = value.value; + if (inner === null || inner === undefined) return false; + if (typeof inner === 'string') return inner.length > 0; + if (Array.isArray(inner)) return inner.length > 0; + return true; + } + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value as object).length > 0; + return false; +} + +/** A "required" param on a node that has no value and no incoming binding. */ +export interface RequiredParamError { + paramName: string; + paramLabel: string; + paramType?: string; +} + +/** Walk a node's parameter spec + values and flag every required-but-unbound. */ +export function findRequiredErrors( + node: CanvasNode, + nodeType: NodeType | undefined, + resolveLabel: (param: NodeTypeParameter) => string = (p) => p.name, +): RequiredParamError[] { + if (!nodeType) return []; + const errors: RequiredParamError[] = []; + const values = node.parameters ?? {}; + for (const param of nodeType.parameters ?? []) { + if (!param.required) continue; + if (isParamBound(values[param.name])) continue; + errors.push({ paramName: param.name, paramLabel: resolveLabel(param), paramType: param.type }); + } + return errors; +} + +/** Map of nodeId → required errors. Empty entries are omitted. */ +export function findGraphErrors( + nodes: CanvasNode[], + nodeTypes: NodeType[], + resolveLabel?: (param: NodeTypeParameter) => string, +): Record { + const byId: Record = {}; + const byTypeId = new Map(nodeTypes.map((nt) => [nt.id, nt])); + for (const n of nodes) { + const errs = findRequiredErrors(n, byTypeId.get(n.type), resolveLabel); + if (errs.length) byId[n.id] = errs; + } + return byId; +} + +/** Resolve the schema produced by an output port (Transit follows incoming connection). */ +function _resolveOutputSchemaName( + nodeId: string, + nodes: CanvasNode[], + connections: CanvasConnection[], + nodeTypes: NodeType[], + visited: Set = new Set(), +): { schemaName?: string; node?: CanvasNode; portDef?: OutputPortDef } { + if (visited.has(nodeId)) return {}; + visited.add(nodeId); + const node = nodes.find((n) => n.id === nodeId); + if (!node) return {}; + const typeDef = nodeTypes.find((nt) => nt.id === node.type); + const port0 = typeDef?.outputPorts?.[0] as OutputPortDef | undefined; + if (!port0) return { node }; + const spec = port0.schema as string | GraphDefinedSchemaRef | undefined; + if (typeof spec === 'object' && spec !== null && spec.kind === 'fromGraph') { + return { schemaName: 'FormPayload_dynamic', node, portDef: port0 }; + } + if (port0.dynamic) { + return { schemaName: 'FormPayload_dynamic', node, portDef: port0 }; + } + if (typeof spec === 'string' && spec !== 'Transit') { + return { schemaName: spec, node, portDef: port0 }; + } + // Transit: follow upstream + const incoming = connections.find((c) => c.targetId === nodeId); + if (!incoming) return { node }; + return _resolveOutputSchemaName(incoming.sourceId, nodes, connections, nodeTypes, visited); +} + +/** Build candidate paths from a schema, recursing into List-element schemas one level deep. */ +function _candidatesFromSchema( + schema: PortSchema | undefined, + catalog: Record, + basePath: (string | number)[] = [], + depth = 0, +): Array<{ path: (string | number)[]; type?: string }> { + if (!schema || !schema.fields || depth > 6) return []; + const out: Array<{ path: (string | number)[]; type?: string }> = []; + for (const field of schema.fields) { + const fieldPath = [...basePath, field.name]; + out.push({ path: fieldPath, type: field.type }); + const inner = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE)?.[1]?.trim() : undefined; + if (inner && catalog[inner]) { + out.push(..._candidatesFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1)); + } + } + return out; +} + +/** + * Compute every typed source candidate that could satisfy `expectedType` + * for the given consumer node. Includes ranked compatibility per candidate + * and a `iterable` flag for List-X→X "iterate as Loop" suggestions. + * + * If `expectedType` is omitted, returns all candidates (all marked 'ok'). + */ +export function findSourceCandidates(args: { + consumerNodeId: string; + expectedType?: string; + nodes: CanvasNode[]; + connections: CanvasConnection[]; + nodeTypes: NodeType[]; + portTypeCatalog: Record; +}): SourceCandidate[] { + const { consumerNodeId, expectedType, nodes, connections, nodeTypes, portTypeCatalog } = args; + const sourceIds = getAvailableSources(consumerNodeId, nodes, connections).filter((id) => { + const n = nodes.find((x) => x.id === id); + return n?.type !== 'trigger.manual'; + }); + + const results: SourceCandidate[] = []; + for (const nid of sourceIds) { + const { schemaName } = _resolveOutputSchemaName(nid, nodes, connections, nodeTypes); + const schema = schemaName ? portTypeCatalog[schemaName] : undefined; + const wholeType = schemaName ?? undefined; + results.push({ + nodeId: nid, + path: [], + type: wholeType, + compat: expectedType && wholeType ? isCompatible(wholeType, expectedType) : 'ok', + iterable: _isIterableMatch(wholeType, expectedType), + }); + for (const cand of _candidatesFromSchema(schema, portTypeCatalog)) { + const compat = expectedType && cand.type ? isCompatible(cand.type, expectedType) : 'ok'; + results.push({ + nodeId: nid, + path: cand.path, + type: cand.type, + compat, + iterable: _isIterableMatch(cand.type, expectedType), + }); + } + } + return results; +} + +/** True iff `producedType` is `List[X]` and `expectedType` equals `X`. */ +function _isIterableMatch(producedType?: string, expectedType?: string): boolean { + if (!producedType || !expectedType) return false; + const m = producedType.match(_LIST_INNER_RE); + if (!m) return false; + return m[1].trim() === expectedType; +} + +/** Filter candidates to only those that satisfy `expectedType` (strict mode). */ +export function strictlyCompatible(candidates: SourceCandidate[]): SourceCandidate[] { + return candidates.filter((c) => c.compat === 'ok' || c.compat === 'coerce' || c.iterable === true); +} diff --git a/src/components/FlowEditor/nodes/shared/scopeHelpers.ts b/src/components/FlowEditor/nodes/shared/scopeHelpers.ts new file mode 100644 index 0000000..2157c2f --- /dev/null +++ b/src/components/FlowEditor/nodes/shared/scopeHelpers.ts @@ -0,0 +1,55 @@ +/** + * Lexical scope for DataPicker: ancestor node ids reachable backward on the graph. + */ + +export interface GraphEdgeLike { + source: string; + target: string; +} + +export interface GraphNodeLike { + id: string; + type?: string; +} + +/** All node ids that can reach targetNodeId via incoming edges (excluding target). */ +export function computeAncestorNodeIds( + _nodes: GraphNodeLike[], + connections: GraphEdgeLike[], + targetNodeId: string +): Set { + const preds = new Map>(); + for (const c of connections) { + const src = c.source; + const tgt = c.target; + if (!src || !tgt) continue; + if (!preds.has(tgt)) preds.set(tgt, new Set()); + preds.get(tgt)!.add(src); + } + const seen = new Set(); + const stack = [targetNodeId]; + while (stack.length) { + const cur = stack.pop()!; + const ps = preds.get(cur); + if (!ps) continue; + for (const p of ps) { + if (!seen.has(p)) { + seen.add(p); + stack.push(p); + } + } + } + seen.delete(targetNodeId); + return seen; +} + +/** Node ids of flow.loop ancestors (subset of ancestors). */ +export function findLoopAncestorIds( + nodes: GraphNodeLike[], + connections: GraphEdgeLike[], + targetNodeId: string +): string[] { + const anc = computeAncestorNodeIds(nodes, connections, targetNodeId); + const byId = new Map(nodes.map((n) => [n.id, n])); + return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop'); +} diff --git a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts index 4b03ecb..1af8319 100644 --- a/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts +++ b/src/components/FormGenerator/FormGeneratorReport/FormGeneratorReportTypes.ts @@ -72,11 +72,11 @@ export interface ReportDateRangeSelectorConfig { * stored selection is available. Default: `'ytd'`. */ defaultPresetKind?: - | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months' + | 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months' | 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom'; /** Whitelist of preset kinds offered to the user. */ enabledPresets?: Array< - 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months' + 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months' | 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'lastN' | 'nextN' | 'custom' >; diff --git a/src/components/PeriodPicker/PeriodPickerLogic.ts b/src/components/PeriodPicker/PeriodPickerLogic.ts index da53817..26e6fac 100644 --- a/src/components/PeriodPicker/PeriodPickerLogic.ts +++ b/src/components/PeriodPicker/PeriodPickerLogic.ts @@ -178,7 +178,7 @@ export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints // for ```` ``min``/``max`` attributes so the browser // refuses invalid years instead of us silently falling back to the default // preset afterwards. -export function clampIsoDate(iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined { +export function clampIsoDate(_iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined { const today = toIsoDate(todayDate()); let lo: string | undefined = cfg.minDate; let hi: string | undefined = cfg.maxDate; diff --git a/src/pages/views/teamsbot/Teamsbot.module.css b/src/pages/views/teamsbot/Teamsbot.module.css index 26afc09..57c31bd 100644 --- a/src/pages/views/teamsbot/Teamsbot.module.css +++ b/src/pages/views/teamsbot/Teamsbot.module.css @@ -416,6 +416,366 @@ height: 100%; } +/* ----- Session Layout (UDB Sidebar + Main) ------------------------------- */ + +.sessionLayout { + display: flex; + flex: 1; + min-height: 0; + gap: 1rem; +} + +.sessionMain { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; + gap: 1rem; +} + +.udbSidebar { + width: 280px; + min-width: 280px; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + display: flex; + flex-direction: column; + background: var(--bg-card, #fff); + overflow: hidden; + position: relative; + transition: width 0.2s, min-width 0.2s; +} + +.udbSidebarCollapsed { + width: 36px; + min-width: 36px; +} + +.udbToggle { + position: absolute; + top: 8px; + right: 4px; + z-index: 2; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 4px; + background: var(--bg-card, #fff); + cursor: pointer; + font-size: 0.65rem; + color: var(--text-secondary, #888); + display: flex; + align-items: center; + justify-content: center; +} + +.udbToggle:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + +@media (max-width: 768px) { + .sessionLayout { + flex-direction: column; + } + .udbSidebar { + width: 100%; + min-width: 0; + max-height: 220px; + } + .udbSidebarCollapsed { + display: none; + } +} + +/* ----- Director Prompt Panel --------------------------------------------- */ + +.directorPanel { + background: var(--surface-color, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + display: flex; + flex-direction: column; + overflow: hidden; + transition: outline-color 0.15s, background 0.15s; +} + +.directorPanelDragOver { + outline: 2px dashed var(--primary-color, #F25843); + outline-offset: -4px; + background: var(--primary-dark-bg, rgba(242, 88, 67, 0.06)); +} + +.botStatusDot { + display: inline-block; + width: 9px; + height: 9px; + border-radius: 50%; + margin-left: 0.25rem; +} + +.botStatusDotLive { + background: #15803d; + box-shadow: 0 0 0 2px rgba(21, 128, 61, 0.18); +} + +.botStatusDotIdle { + background: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.18); + animation: directorPulse 1.6s ease-in-out infinite; +} + +@keyframes directorPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +.directorAttachBtn { + border: 1px solid var(--border-color, #ddd); + background: var(--bg-card, #fff); + border-radius: 6px; + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; + color: var(--text-secondary, #666); +} + +.directorAttachBtn:hover:not(:disabled) { + border-color: var(--primary-color, #F25843); + color: var(--primary-color, #F25843); +} + +.directorAttachBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.directorHint { + font-size: 0.75rem; + color: var(--text-secondary, #888); + background: var(--surface-alt, #fafafa); + padding: 0.4rem 0.6rem; + border-radius: 6px; + border: 1px dashed var(--border-color, #ddd); +} + +.directorHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + background: var(--surface-alt, #fafafa); +} + +.directorHeaderLeft { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.directorTitle { + margin: 0; + font-size: 0.9rem; + font-weight: 600; +} + +.directorBadge { + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 10px; + background: var(--primary-color, #F25843); + color: #fff; + font-weight: 600; +} + +.directorBody { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1rem; +} + +.directorTextarea { + width: 100%; + min-height: 70px; + max-height: 200px; + resize: vertical; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-family: inherit; + font-size: 0.9rem; + background: var(--bg-card, #fff); + color: var(--text-primary, #333); +} + +.directorTextarea:focus { + outline: none; + border-color: var(--primary-color, #F25843); +} + +.directorRow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; +} + +.directorChips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + padding: 0.25rem 0; +} + +.directorChip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.5rem; + background: var(--surface-alt, #f0f4f8); + border: 1px solid var(--border-color, #ddd); + border-radius: 12px; + font-size: 0.75rem; + max-width: 180px; +} + +.directorChipName { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.directorChipRemove { + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary, #888); + font-size: 0.85rem; + line-height: 1; + padding: 0; +} + +.directorChipRemove:hover { + color: var(--primary-color, #F25843); +} + +.directorActions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.directorMeta { + display: flex; + gap: 0.75rem; + font-size: 0.72rem; + color: var(--text-secondary, #888); +} + +.directorSubmit { + padding: 0.4rem 0.9rem; + background: var(--primary-color, #F25843); + color: #fff; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; +} + +.directorSubmit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.directorModeToggle { + display: inline-flex; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + overflow: hidden; +} + +.directorModeButton { + border: none; + background: var(--bg-card, #fff); + padding: 0.25rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; + color: var(--text-secondary, #666); +} + +.directorModeButtonActive { + background: var(--primary-color, #F25843); + color: #fff; +} + +.directorHistory { + border-top: 1px dashed var(--border-color, #e0e0e0); + padding: 0.5rem 1rem; + max-height: 180px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.directorHistoryItem { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.4rem 0.5rem; + border: 1px solid var(--border-color, #eee); + border-radius: 6px; + background: var(--surface-alt, #fafafa); + font-size: 0.78rem; +} + +.directorHistoryHead { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary, #666); + font-size: 0.7rem; +} + +.directorHistoryText { + color: var(--text-primary, #333); + white-space: pre-wrap; + word-break: break-word; +} + +.directorStatus { + font-size: 0.7rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 8px; +} + +.directorStatusQueued { background: #e6efff; color: #1d4ed8; } +.directorStatusRunning { background: #fff7e0; color: #b45309; } +.directorStatusSucceeded { background: #e6f7ec; color: #15803d; } +.directorStatusFailed { background: #fde2e1; color: #b91c1c; } +.directorStatusConsumed { background: #eee; color: #555; } + +.directorRemoveBtn { + border: none; + background: transparent; + cursor: pointer; + color: var(--text-secondary, #888); + font-size: 0.8rem; +} + +.directorRemoveBtn:hover { + color: var(--primary-color, #F25843); +} + .sessionViewHeader { display: flex; justify-content: space-between; @@ -579,6 +939,35 @@ max-width: 720px; } +/* Tabs */ +.settingsTabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border-color, #e0e0e0); + margin-bottom: 1rem; +} + +.settingsTab { + padding: 0.6rem 1.1rem; + background: transparent; + color: var(--text-secondary, #666); + border: none; + border-bottom: 2px solid transparent; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.settingsTab:hover { + color: var(--text-color, #333); +} + +.settingsTabActive { + color: var(--primary-color, #4A90D9); + border-bottom-color: var(--primary-color, #4A90D9); +} + .settingsCard { background: var(--surface-color, #fff); border: 1px solid var(--border-color, #e0e0e0); diff --git a/src/pages/views/teamsbot/TeamsbotSessionView.tsx b/src/pages/views/teamsbot/TeamsbotSessionView.tsx index f12a2f0..72970e2 100644 --- a/src/pages/views/teamsbot/TeamsbotSessionView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSessionView.tsx @@ -2,8 +2,23 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useSearchParams } from 'react-router-dom'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import * as teamsbotApi from '../../../api/teamsbotApi'; -import type { TeamsbotSession, TeamsbotTranscript, TeamsbotBotResponse, TeamsbotSSEEvent, ScreenshotInfo } from '../../../api/teamsbotApi'; +import type { + TeamsbotSession, + TeamsbotTranscript, + TeamsbotBotResponse, + TeamsbotSSEEvent, + ScreenshotInfo, + DirectorPrompt, + DirectorPromptMode, +} from '../../../api/teamsbotApi'; +import { + DIRECTOR_PROMPT_TEXT_LIMIT, + DIRECTOR_PROMPT_FILE_LIMIT, +} from '../../../api/teamsbotApi'; import { getUserDataCache } from '../../../utils/userCache'; +import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; +import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; +import { useFileContext } from '../../../contexts/FileContext'; import styles from './Teamsbot.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -41,6 +56,32 @@ export const TeamsbotSessionView: React.FC = () => { timestamp: string; }>>([]); + // Director Prompt panel state + const [directorPrompts, setDirectorPrompts] = useState([]); + const [directorText, setDirectorText] = useState(''); + const [directorMode, setDirectorMode] = useState('oneShot'); + const [directorFiles, setDirectorFiles] = useState>([]); + const [directorSubmitting, setDirectorSubmitting] = useState(false); + const [directorError, setDirectorError] = useState(null); + const [directorDragOver, setDirectorDragOver] = useState(false); + const [directorUploading, setDirectorUploading] = useState(false); + const directorDragCounterRef = useRef(0); + const directorFileInputRef = useRef(null); + + // Bot WebSocket connection state (separate from session.status: the session + // can be 'active' before the bot has actually opened its WebSocket back to + // the gateway. Director prompts can only be processed once botConnected=true.) + const [botConnected, setBotConnected] = useState(false); + + // UDB Sidebar state + const [udbCollapsed, setUdbCollapsed] = useState(false); + const [udbTab, setUdbTab] = useState('files'); + const _udbContext: UdbContext | null = instanceId + ? { instanceId, featureInstanceId: instanceId } + : null; + + const fileCtx = useFileContext(); + const transcriptEndRef = useRef(null); const eventSourceRef = useRef(null); @@ -98,14 +139,38 @@ export const TeamsbotSessionView: React.FC = () => { _loadSession(); }, [_loadSession]); - // SSE Live Stream - connect once per session, don't re-create on status changes - const sseSessionRef = useRef(null); - const sessionStatus = session?.status; + // Load director prompt history when session changes useEffect(() => { - if (!instanceId || !sessionId || !sessionStatus) return; - if (!['active', 'joining', 'pending'].includes(sessionStatus)) return; + if (!instanceId || !sessionId) return; + let cancelled = false; + teamsbotApi + .listDirectorPrompts(instanceId, sessionId) + .then((res) => { + if (!cancelled) setDirectorPrompts(res.prompts || []); + }) + .catch(() => { + if (!cancelled) setDirectorPrompts([]); + }); + return () => { + cancelled = true; + }; + }, [instanceId, sessionId]); + + // SSE Live Stream - connect once per session, don't re-create on status changes. + // We deliberately depend ONLY on (instanceId, sessionId), not on session.status, + // so transient status transitions (pending -> joining -> active) don't tear down + // and rebuild the EventSource (which used to flicker botConnected and spawn + // multiple parallel /stream connections to the gateway). + const sseSessionRef = useRef(null); + const sessionStatusRef = useRef(session?.status); + sessionStatusRef.current = session?.status; + useEffect(() => { + if (!instanceId || !sessionId) return; // Avoid reconnecting if already streaming this session if (sseSessionRef.current === sessionId && eventSourceRef.current) return; + // Don't open a stream for sessions that are known to already be terminal. + const initialStatus = sessionStatusRef.current; + if (initialStatus && !['active', 'joining', 'pending'].includes(initialStatus)) return; eventSourceRef.current?.close(); sseSessionRef.current = sessionId; @@ -200,6 +265,34 @@ export const TeamsbotSessionView: React.FC = () => { break; } + case 'botConnectionState': { + const data = sseEvent.data || {}; + setBotConnected(Boolean(data.connected)); + _dlog('BOT-WS', data.connected ? 'connected' : 'disconnected'); + break; + } + + case 'directorPrompt': { + const prompt = sseEvent.data as DirectorPrompt | undefined; + if (!prompt || !prompt.id) break; + setDirectorPrompts((prev) => { + const idx = prev.findIndex((p) => p.id === prompt.id); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], ...prompt }; + return updated; + } + return [prompt, ...prev]; + }); + break; + } + + case 'agentRun': { + const data = sseEvent.data || {}; + _dlog('AGENT', `${data.status || ''} ${data.reason || ''}`.trim()); + break; + } + case 'error': { const errData = sseEvent.data || {}; const errMsg = errData.message || t('Unbekannter Fehler'); @@ -229,8 +322,10 @@ export const TeamsbotSessionView: React.FC = () => { eventSourceRef.current = null; sseSessionRef.current = null; setIsLive(false); + setBotConnected(false); }; - }, [instanceId, sessionId, sessionStatus, _dlog, t]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId, sessionId]); // Polling fallback: refresh session data every 5s when SSE is not connected const pollRef = useRef | null>(null); @@ -274,6 +369,193 @@ export const TeamsbotSessionView: React.FC = () => { } }; + const _addDirectorFile = useCallback((fileId: string, fileName?: string) => { + setDirectorFiles((prev) => { + if (prev.some((f) => f.id === fileId)) return prev; + if (prev.length >= DIRECTOR_PROMPT_FILE_LIMIT) { + setDirectorError( + t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }), + ); + return prev; + } + setDirectorError(null); + return [...prev, { id: fileId, name: fileName || fileId }]; + }); + }, [t]); + + const _handleUdbFileSelect = _addDirectorFile; + + const _removeDirectorFile = (fileId: string) => { + setDirectorFiles((prev) => prev.filter((f) => f.id !== fileId)); + }; + + const _uploadAndAttachDirectorFile = useCallback(async (file: File) => { + if (!fileCtx?.handleFileUpload) return; + setDirectorUploading(true); + setDirectorError(null); + try { + const result = await fileCtx.handleFileUpload(file); + if (result?.success) { + const data: any = (result.fileData as any)?.file || result.fileData; + const id = data?.id || (result.fileData as any)?.id; + if (id) { + _addDirectorFile(id, data?.fileName || file.name); + } else { + setDirectorError(t('Upload erfolgreich, aber keine Datei-ID erhalten.')); + } + } else { + setDirectorError(result?.error || t('Upload fehlgeschlagen.')); + } + } catch (err: any) { + setDirectorError(err?.message || t('Upload fehlgeschlagen.')); + } finally { + setDirectorUploading(false); + } + }, [fileCtx, _addDirectorFile, t]); + + const _onDirectorDragEnter = useCallback((e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes('Files') || + e.dataTransfer.types.includes('application/file-id') || + e.dataTransfer.types.includes('application/file-ids') || + e.dataTransfer.types.includes('application/tree-items') + ) { + e.preventDefault(); + e.stopPropagation(); + directorDragCounterRef.current += 1; + setDirectorDragOver(true); + } + }, []); + + const _onDirectorDragOver = useCallback((e: React.DragEvent) => { + if ( + e.dataTransfer.types.includes('Files') || + e.dataTransfer.types.includes('application/file-id') || + e.dataTransfer.types.includes('application/file-ids') || + e.dataTransfer.types.includes('application/tree-items') + ) { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'copy'; + } + }, []); + + const _onDirectorDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + directorDragCounterRef.current = Math.max(0, directorDragCounterRef.current - 1); + if (directorDragCounterRef.current === 0) setDirectorDragOver(false); + }, []); + + const _onDirectorDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + directorDragCounterRef.current = 0; + setDirectorDragOver(false); + + const fileIdsJson = e.dataTransfer.getData('application/file-ids'); + if (fileIdsJson) { + try { + const ids: string[] = JSON.parse(fileIdsJson); + ids.forEach((id) => _addDirectorFile(id)); + } catch { /* ignore malformed */ } + return; + } + + const singleFileId = e.dataTransfer.getData('application/file-id'); + if (singleFileId) { + const label = e.dataTransfer.getData('text/plain'); + _addDirectorFile(singleFileId, label || undefined); + return; + } + + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); + if (treeItemsJson) { + try { + const items: Array<{ id: string; type: 'file' | 'folder'; name: string }> = JSON.parse(treeItemsJson); + items.filter((it) => it.type === 'file').forEach((it) => _addDirectorFile(it.id, it.name)); + } catch { /* ignore malformed */ } + return; + } + + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + for (const file of Array.from(e.dataTransfer.files)) { + if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) { + setDirectorError( + t('Maximal {n} Dateien pro Regieanweisung.', { n: String(DIRECTOR_PROMPT_FILE_LIMIT) }), + ); + break; + } + await _uploadAndAttachDirectorFile(file); + } + } + }, [_addDirectorFile, _uploadAndAttachDirectorFile, directorFiles.length, t]); + + const _onDirectorFileInput = useCallback(async (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + for (const file of Array.from(e.target.files)) { + if (directorFiles.length >= DIRECTOR_PROMPT_FILE_LIMIT) break; + await _uploadAndAttachDirectorFile(file); + } + e.target.value = ''; + }, [_uploadAndAttachDirectorFile, directorFiles.length]); + + const _submitDirectorPrompt = async () => { + if (!instanceId || !sessionId) return; + const trimmed = directorText.trim(); + if (!trimmed) { + setDirectorError(t('Bitte gib eine Anweisung ein.')); + return; + } + if (trimmed.length > DIRECTOR_PROMPT_TEXT_LIMIT) { + setDirectorError( + t('Text zu lang (max. {n} Zeichen).', { n: String(DIRECTOR_PROMPT_TEXT_LIMIT) }), + ); + return; + } + setDirectorSubmitting(true); + setDirectorError(null); + try { + const res = await teamsbotApi.submitDirectorPrompt(instanceId, sessionId, { + text: trimmed, + mode: directorMode, + fileIds: directorFiles.map((f) => f.id), + }); + if (res.prompt) { + setDirectorPrompts((prev) => { + const idx = prev.findIndex((p) => p.id === res.prompt.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = res.prompt; + return next; + } + return [res.prompt, ...prev]; + }); + } + setDirectorText(''); + setDirectorFiles([]); + } catch (err: any) { + setDirectorError(err?.response?.data?.detail || err?.message || t('Senden fehlgeschlagen.')); + } finally { + setDirectorSubmitting(false); + } + }; + + const _removeDirectorPrompt = async (promptId: string) => { + if (!instanceId || !sessionId) return; + try { + await teamsbotApi.deleteDirectorPrompt(instanceId, sessionId, promptId); + setDirectorPrompts((prev) => prev.filter((p) => p.id !== promptId)); + } catch (err: any) { + setDirectorError(err?.message || t('Entfernen fehlgeschlagen.')); + } + }; + + const activePersistentCount = useMemo( + () => directorPrompts.filter((p) => p.mode === 'persistent' && p.status !== 'consumed').length, + [directorPrompts], + ); + const _getSpeakerColor = (speaker: string) => { const colors = ['#4A90D9', '#D94A4A', '#4AD99A', '#D9A84A', '#9A4AD9', '#4AD9D9']; let hash = 0; @@ -341,6 +623,227 @@ export const TeamsbotSessionView: React.FC = () => { {error &&
{error}
} + {/* Layout: UDB Sidebar + Main */} +
+ {/* UDB Sidebar (Files / Sources) */} + {_udbContext && ( +
+ + {!udbCollapsed && ( + + )} +
+ )} + + {/* Main column */} +
+ + {/* Director Prompt Panel (private operator instructions) */} + {['active', 'joining', 'pending'].includes(session.status) && ( +
+ {(() => { + const sStatus = session?.status; + const isSessionLaunching = !!sStatus && ['pending', 'joining'].includes(sStatus); + const isSessionActive = sStatus === 'active'; + // Bot has joined the meeting (session active) but the WebSocket back + // to the gateway is missing -> usually means the browser-bot service + // can't reach this gateway (e.g. localhost gateway + remote bot, or + // bot behind firewall). Audio + transcripts won't flow. + const isBotUnreachable = isSessionActive && !botConnected; + const statusLabel = botConnected + ? t('Bot live') + : isBotUnreachable + ? t('Bot ist im Meeting, aber nicht mit dem Gateway verbunden') + : isSessionLaunching + ? t('Bot startet ...') + : t('Keine aktive Session'); + const statusTitle = botConnected + ? t('Bot ist live im Meeting verbunden und liefert Transkripte') + : isBotUnreachable + ? t('Der Browser-Bot hat den WebSocket nicht zurueck zum Gateway geoeffnet. Pruefe TEAMSBOT_BROWSER_BOT_URL und APP_API_URL: bei lokalem Gateway muss der Bot ebenfalls lokal laufen oder das Gateway ueber einen Tunnel erreichbar sein.') + : isSessionLaunching + ? t('Bot tritt dem Meeting bei und oeffnet die WebSocket-Verbindung ...') + : t('Es laeuft keine aktive Bot-Session'); + return ( +
+
+

{t('Regieanweisungen')}

+ + + {statusLabel} + + {activePersistentCount > 0 && ( + + {activePersistentCount} + + )} +
+
+ {t('Privat - nur fuer den Bot sichtbar')} +
+
+ ); + })()} + +
+